Skip to content

Commit 0752b45

Browse files
authored
OAuth2 기반 소셜 로그인 추가하기 (#23)
* 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 소셜 로그인 추가
1 parent 891ab7f commit 0752b45

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+4445
-31
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,6 @@ out/
4646
.kotlin
4747

4848
### log ###
49-
logs/
49+
logs/
50+
51+
/src/main/docker/.env

build.gradle.kts

+5
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ dependencies {
7070

7171
// security
7272
implementation("org.springframework.boot:spring-boot-starter-security")
73+
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
74+
75+
// thymeleaf
76+
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
77+
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
7378

7479
// testcontainers
7580
testImplementation("org.testcontainers:junit-jupiter")

src/main/docker/docker-compose.yml

+2-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ services:
33
app:
44
container_name: ticketaka-dev
55
image: ticketaka-dev
6-
environment:
7-
- JAVA_OPTS=-Xmx1200m -Xms1200m
8-
- SPRING_PROFILES_ACTIVE=dev
6+
env_file:
7+
- .env
98
ports:
109
- 8080:8080
1110

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.ticketaka.api.common.infrastructure.security
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import io.ticketaka.api.common.ApiError
5+
import jakarta.servlet.http.HttpServletRequest
6+
import jakarta.servlet.http.HttpServletResponse
7+
import org.springframework.security.access.AccessDeniedException
8+
import org.springframework.security.web.access.AccessDeniedHandler
9+
import org.springframework.stereotype.Component
10+
11+
@Component
12+
class DefaultAccessDeniedHandler(
13+
private val objectMapper: ObjectMapper,
14+
) : AccessDeniedHandler {
15+
override fun handle(
16+
request: HttpServletRequest,
17+
response: HttpServletResponse,
18+
accessDeniedException: AccessDeniedException?,
19+
) {
20+
response.status = HttpServletResponse.SC_FORBIDDEN
21+
val outputStream = response.outputStream
22+
objectMapper
23+
.writeValue(outputStream, ApiError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."))
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.ticketaka.api.common.infrastructure.security
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import io.ticketaka.api.common.ApiError
5+
import jakarta.servlet.http.HttpServletRequest
6+
import jakarta.servlet.http.HttpServletResponse
7+
import org.springframework.security.core.AuthenticationException
8+
import org.springframework.security.web.AuthenticationEntryPoint
9+
import org.springframework.stereotype.Component
10+
11+
@Component
12+
class DefaultAuthenticationEntryPoint(
13+
private val objectMapper: ObjectMapper,
14+
) : AuthenticationEntryPoint {
15+
override fun commence(
16+
request: HttpServletRequest,
17+
response: HttpServletResponse,
18+
authException: AuthenticationException?,
19+
) {
20+
response.status = HttpServletResponse.SC_UNAUTHORIZED
21+
response.setHeader("content-type", "application/json;charset=utf8")
22+
val outputStream = response.outputStream
23+
objectMapper
24+
.writeValue(outputStream, ApiError(HttpServletResponse.SC_UNAUTHORIZED, "인증되지 않은 사용자입니다."))
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.ticketaka.api.common.infrastructure.security
2+
3+
import io.ticketaka.api.user.application.CustomOAuth2UserService
4+
import io.ticketaka.api.user.application.CustomOidcUserService
5+
import io.ticketaka.api.user.infrastructure.CustomAuthorityMapper
6+
import io.ticketaka.api.user.infrastructure.jwt.JwtAuthenticationFilter
7+
import io.ticketaka.api.user.infrastructure.jwt.JwtExceptionFilter
8+
import org.springframework.context.annotation.Bean
9+
import org.springframework.context.annotation.Configuration
10+
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
11+
import org.springframework.security.config.annotation.web.builders.HttpSecurity
12+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
13+
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
14+
import org.springframework.security.web.AuthenticationEntryPoint
15+
import org.springframework.security.web.SecurityFilterChain
16+
import org.springframework.security.web.access.AccessDeniedHandler
17+
import org.springframework.security.web.access.ExceptionTranslationFilter
18+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
19+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
20+
21+
@Configuration
22+
@EnableWebSecurity(debug = true)
23+
@EnableMethodSecurity
24+
class SecurityConfig(
25+
private val accessDeniedHandler: AccessDeniedHandler,
26+
private val authenticationEntryPoint: AuthenticationEntryPoint,
27+
private val customOAuth2UserService: CustomOAuth2UserService,
28+
private val customOidcUserService: CustomOidcUserService,
29+
private val jwtExceptionFilter: JwtExceptionFilter,
30+
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
31+
) {
32+
/*
33+
*
34+
Security filter chain: [
35+
DisableEncodeUrlFilter
36+
WebAsyncManagerIntegrationFilter
37+
SecurityContextHolderFilter
38+
HeaderWriterFilter
39+
CorsFilter
40+
LogoutFilter
41+
OAuth2AuthorizationRequestRedirectFilter
42+
OAuth2LoginAuthenticationFilter
43+
JwtAuthenticationFilter
44+
DefaultLoginPageGeneratingFilter
45+
DefaultLogoutPageGeneratingFilter
46+
RequestCacheAwareFilter
47+
SecurityContextHolderAwareRequestFilter
48+
AnonymousAuthenticationFilter
49+
SessionManagementFilter
50+
JwtExceptionFilter
51+
ExceptionTranslationFilter
52+
AuthorizationFilter
53+
]
54+
55+
* */
56+
@Bean
57+
fun filterChain(http: HttpSecurity): SecurityFilterChain {
58+
http
59+
.csrf { it.disable() }
60+
// .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
61+
.authorizeHttpRequests { it.anyRequest().permitAll() } // TODO oauth 구현이후 endpoint별 권한 설정 필요
62+
.formLogin {
63+
it
64+
.loginPage("/login")
65+
.loginProcessingUrl("/loginProc")
66+
.defaultSuccessUrl("/")
67+
.permitAll()
68+
}.oauth2Login { oauth2LoginCustomizer ->
69+
oauth2LoginCustomizer.userInfoEndpoint { userInfoEndpointConfig ->
70+
userInfoEndpointConfig
71+
.userService(customOAuth2UserService)
72+
// .oidcUserService(null)
73+
}
74+
}.exceptionHandling { it.authenticationEntryPoint(LoginUrlAuthenticationEntryPoint("/login")) }
75+
.logout { it.logoutSuccessUrl("/") }
76+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
77+
.addFilterBefore(jwtExceptionFilter, ExceptionTranslationFilter::class.java)
78+
return http.build()
79+
}
80+
81+
@Bean
82+
fun exceptionTranslationFilter(): ExceptionTranslationFilter {
83+
val filter = ExceptionTranslationFilter(authenticationEntryPoint)
84+
filter.setAccessDeniedHandler(accessDeniedHandler)
85+
return filter
86+
}
87+
88+
@Bean
89+
fun customAuthorityMapper(): GrantedAuthoritiesMapper = CustomAuthorityMapper()
90+
}

src/main/kotlin/io/ticketaka/api/point/domain/PointHistory.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import java.time.LocalDateTime
1919
@Table(name = "point_histories")
2020
class PointHistory(
2121
@Id
22-
val id: Long = 0,
22+
val id: Long,
2323
@Enumerated(EnumType.STRING)
2424
val transactionType: TransactionType,
2525
val userId: Long,
@@ -31,7 +31,7 @@ class PointHistory(
3131
private set
3232

3333
@Column(nullable = false)
34-
var updatedAt: LocalDateTime? = null
34+
var updatedAt: LocalDateTime? = LocalDateTime.now()
3535
private set
3636

3737
@PreUpdate
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package io.ticketaka.api.user.application
2+
3+
import io.ticketaka.api.user.domain.ProviderUser
4+
import io.ticketaka.api.user.domain.User
5+
import io.ticketaka.api.user.domain.UserRepository
6+
import io.ticketaka.api.user.infrastructure.oauth2.PrincipalUser
7+
import io.ticketaka.api.user.infrastructure.oauth2.ProviderUserRequest
8+
import io.ticketaka.api.user.infrastructure.oauth2.converter.ProviderUserConverter
9+
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
10+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
11+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
12+
import org.springframework.security.oauth2.core.user.OAuth2User
13+
import org.springframework.stereotype.Service
14+
15+
@Service
16+
class CustomOAuth2UserService(
17+
private val userRepository: UserRepository,
18+
private val providerUserConverter: ProviderUserConverter<ProviderUserRequest, ProviderUser>,
19+
) : OAuth2UserService<OAuth2UserRequest, OAuth2User> {
20+
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
21+
val clientRegistration = userRequest.clientRegistration
22+
val oAuth2UserService = DefaultOAuth2UserService()
23+
val oAuth2User = oAuth2UserService.loadUser(userRequest)
24+
25+
val providerUserRequest = ProviderUserRequest(clientRegistration, oAuth2User)
26+
val providerUser =
27+
providerUserConverter.convert(providerUserRequest)
28+
?: throw IllegalArgumentException("Not supported provider")
29+
30+
registerIfAbsent(providerUser)
31+
32+
return PrincipalUser(providerUser)
33+
}
34+
35+
private fun registerIfAbsent(providerUser: ProviderUser) {
36+
val user = userRepository.findByEmail(providerUser.getEmail())
37+
if (user == null) {
38+
userRepository.save(User.newInstance(providerUser.getEmail()))
39+
}
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.ticketaka.api.user.application
2+
3+
import org.springframework.stereotype.Service
4+
5+
@Service
6+
class CustomOidcUserService
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package io.ticketaka.api.user.domain
2+
3+
data class Attributes(
4+
val mainAttributes: Map<String, Any>,
5+
val subAttributes: Map<String, Any> = emptyMap(),
6+
val otherAttributes: Map<String, Any> = emptyMap(),
7+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.ticketaka.api.user.domain
2+
3+
data class AuthenticatedUser(
4+
val userId: Long,
5+
val roles: Set<Role>,
6+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package io.ticketaka.api.user.domain
2+
3+
import org.springframework.security.core.GrantedAuthority
4+
5+
interface ProviderUser {
6+
fun getId(): String
7+
8+
fun getUsername(): String
9+
10+
fun getPassword(): String
11+
12+
fun getEmail(): String
13+
14+
fun getProvider(): String
15+
16+
fun getRoles(): List<GrantedAuthority>
17+
18+
fun getAttributes(): Map<String, Any>
19+
}

src/main/kotlin/io/ticketaka/api/user/domain/User.kt

+24-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package io.ticketaka.api.user.domain
22

33
import io.ticketaka.api.common.infrastructure.tsid.TsIdKeyGenerator
4+
import io.ticketaka.api.point.domain.Point
45
import jakarta.persistence.Column
6+
import jakarta.persistence.ElementCollection
57
import jakarta.persistence.Entity
8+
import jakarta.persistence.EnumType
9+
import jakarta.persistence.Enumerated
10+
import jakarta.persistence.FetchType
611
import jakarta.persistence.Id
712
import jakarta.persistence.PostLoad
813
import jakarta.persistence.PrePersist
@@ -18,17 +23,17 @@ class User protected constructor(
1823
@Id
1924
val id: Long,
2025
var pointId: Long,
26+
val email: String,
27+
@ElementCollection(fetch = FetchType.EAGER)
28+
@Enumerated(EnumType.STRING)
29+
var roles: MutableSet<Role> = hashSetOf(Role.USER),
2130
) : Persistable<Long> {
2231
@Transient
2332
private var isNew = true
2433

25-
override fun isNew(): Boolean {
26-
return isNew
27-
}
34+
override fun isNew(): Boolean = isNew
2835

29-
override fun getId(): Long {
30-
return id
31-
}
36+
override fun getId(): Long = id
3237

3338
@PrePersist
3439
@PostLoad
@@ -41,7 +46,7 @@ class User protected constructor(
4146
private set
4247

4348
@Column(nullable = false)
44-
var updatedAt: LocalDateTime? = null
49+
var updatedAt: LocalDateTime? = LocalDateTime.now()
4550
private set
4651

4752
@PreUpdate
@@ -50,12 +55,19 @@ class User protected constructor(
5055
}
5156

5257
companion object {
53-
fun newInstance(pointId: Long): User {
54-
return User(
58+
fun newInstance(pointId: Long): User =
59+
User(
5560
id = TsIdKeyGenerator.nextLong(),
61+
email = "",
5662
pointId = pointId,
5763
)
58-
}
64+
65+
fun newInstance(email: String): User =
66+
User(
67+
id = TsIdKeyGenerator.nextLong(),
68+
email = email,
69+
pointId = Point.newInstance().getId(),
70+
)
5971
}
6072

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

6779
if (id != other.id) return false
80+
if (pointId != other.pointId) return false
6881

6982
return true
7083
}
7184

7285
override fun hashCode(): Int {
7386
var result = id.hashCode()
74-
result = 31 * result + id.hashCode()
87+
result = 31 * result + pointId.hashCode()
7588
return result
7689
}
7790
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package io.ticketaka.api.user.domain
22

33
interface UserRepository {
4+
fun save(user: User): User
5+
46
fun findById(id: Long): User?
7+
8+
fun findByEmail(email: String): User?
59
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package io.ticketaka.api.user.domain.token
22

33
interface TokenExtractor {
4-
fun extract(payload: String): String
4+
fun extract(payload: String?): String
55
}

0 commit comments

Comments
 (0)