๐ก SNS ๋ก๊ทธ์ธ(์นด์นด์ค)์ ์ํ Rest API ์์ฑ
โก๏ธ 1ํ ๋ฐ๋ก๊ฐ๊ธฐ: ์ค์ ๋ฐ ํ์๊ฐ์
โก๏ธ 2ํ ๋ฐ๋ก๊ฐ๊ธฐ: JWT ๋ก๊ทธ์ธ ๋ฐ ํ์ ์ ๋ณด ์กฐํ
โก๏ธ 3ํ ๋ฐ๋ก๊ฐ๊ธฐ: Refresh Token
SNS ๋ก๊ทธ์ธ Flow
ํด๋น ํ๋ก์ ํธ์์๋ ๋ชจ๋ฐ์ผ ์ดํ๋ฆฌ์ผ์ด์ ์ ์ํ Rest API๋ฅผ ๊ตฌํํ๋ค.
- Client SDK์์ ์นด์นด์ค ๋ก๊ทธ์ธ ์๋ฃ ํ 3rd๋ก๋ถํฐ ์ ํด๋ฐ๋ accesToken์ Server์ ์ ๋ฌํ๋ค.
- ํด๋น AccessToken์ผ๋ก ๋ค์ํ๋ฒ 3rd์ ์กฐํํ์ฌ, ํ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์จ๋ค.
- ์กฐํํ ํ์ ์ ๋ณด๊ฐ ์์ ๊ฒฝ์ฐ, ๋ฐ๋ก ๋ก๊ทธ์ธ ๋ก์ง์ ์คํํ์ฌ ์ฑ๊ณต ์๋ต์ ๋ณด๋ธ๋ค.
- ์กฐํํ ํ์ ์ ๋ณด๊ฐ ์์ ๊ฒฝ์ฐ, ํ์ ์ ๋ณด๋ฅผ ์๋ก ์ ์ฅ(ํ์๊ฐ์ )ํ๊ณ ๋ก๊ทธ์ธ๊น์ง ์ฑ๊ณต์์ผ ์๋ต์ ๋ณด๋ธ๋ค.
๊ฐ๋ฐ ํ๊ฒฝ
- Spring Boot 3.x.x / Kotlin
- Spring Security 3.1.4
- JWT 0.11.5
- Kakao Developers
Member Entity
๊ธฐ์กด member entity์ social ํ์์ ์ํ ํ๋ ์ถ๊ฐ
@Entity
@Table(name = "member")
class Member(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: UUID? = null,
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
val password: String,
val name: String,
@Enumerated(value = EnumType.STRING)
val type: MemberType = MemberType.MEMBER,
@Enumerated(value = EnumType.STRING)
val socialType: SocialType? = null, // ADD: ์์
ํ์์ผ ์, ํ์
์ ์ ์ฅํ๋ค
val createdAt: LocalDateTime = LocalDateTime.now()
) {
fun toInfoDto() = MemberInfoResponse(
email,
name
)
}
enum class SocialType(val value: String){
KAKAO("kakao");
companion object {
fun from(value: String): SocialType = SocialType.values().first { it.value == value }
}
}
SocialAuthController
Client๋ก๋ถํฐ SNS ๋ก๊ทธ์ธ์ ํ์ํ ์ ๋ณด๋ฅผ ๋ฐ์
@RestController
@RequestMapping("/auth")
class AuthController(
private val socialAuthService: SocialAuthService
) {
@GetMapping("/sns/{type}")
fun loginSocialUser(
@RequestHeader("Authorization") token: String, @PathVariable("type") type: String
): BaseResponse<TokenInfo> = socialAuthService.signInWithSocial(token, type).let {
BaseResponse(data = it)
}
}
- `token`: ํด๋ผ์ด์ธํธ์์ ์งํ๋ ‘SNS ๋ก๊ทธ์ธ’ ๊ฒฐ๊ณผ๋ก ๋ฐํ๋ ํ ํฐ
- `type`: ๋ก๊ทธ์ธํ ‘SNS’ ํ์ (kakao, naver …)
SocialAuthService
SNS ํ์์ ์ํ Authentication ๋ก์ง์ ๋ด๋น
@Service
class SocialAuthService(
private val memberRepository: MemberRepository
) {
fun signInWithSocial(token: String, type: String): TokenInfo {
val socialType = SocialType.from(type)
val socialUser = getSocialUserFromType(token, socialType)
val member = memberRepository.findByEmailAndSocialType(socialUser.email, socialType)
?: signUpWithSocial(socialUser, socialType)
return createTokenWithMember(member)
}
}
- ํด๋ผ์ด์ธํธ๋ก๋ถํฐ ๋ฐ์ type ๊ณผ token ์ผ๋ก ํด๋น sns์ ์ ์ ์ ๋ณด ์กฐํ
- ์กฐํ๋ ๋ฉค๋ฒ์ ์ด๋ฉ์ผ๊ณผ SNS ํ์
์ด ๋์ผํ ์ ์ ๊ฐ ์ด๋ฏธ ๋ฑ๋ก๋์ด์๋์ง ํ์ธ
- ์ด๋ฏธ ๋ฑ๋ก๋์ด ์๋ค๋ฉด, ๋ฐ๋ก ๋ก๊ทธ์ธ ํ ํฐ ๋ฐ๊ธ
- ๋ฑ๋ก๋์ด์์ง ์๋ค๋ฉด, ํ์๊ฐ์ ๋ก์ง ์ ํ ํ ๋ก๊ทธ์ธ ํ ํฐ ๋ฐ๊ธ
Social Type์ ๋ฐ๋ผ์ ์ ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ Client๋ฅผ ํธ์ถ
private fun getSocialUserFromType(token: String, type: SocialType): SocialUser {
return when (type) {
SocialType.KAKAO -> {
socialClient.getKakaoUser(token).run {
if (kakaoAccount == null) throw AuthenticationCredentialsNotFoundException("kakao ๊ณ์ ์ด ์กด์ฌํ์ง ์์ต๋๋ค.")
else SocialUser(
email = kakaoAccount.email, name = kakaoAccount.name
)
}
}
}
}
- ๊ฐ SNS Client ํธ์ถ ํ SNS ์ ์ ์ ๋ณด๋ฅผ ๋ฐ์์ด
- ํด๋น ์ ์ ์ ๋ณด๋ก SocialUser๋ฅผ ์์ฑ ํ ๋ฐํ
SNS ํ์ ๊ฐ์ ๋ก์ง
private fun signUpWithSocial(socialUser: SocialUser, socialType: SocialType): Member {
memberRepository.findByEmail(socialUser.email)?.let {
throw DuplicateKeyException("Already Exists")
}
val member = Member(
email = socialUser.email,
name = socialUser.name,
socialType = socialType,
password = passwordEncoder.encode(
listOf(
socialType, socialUser.email, socialUser.name
).joinToString("")
)
)
memberRepository.save(member)
return member
}
- SNS ํ์์ password๋ก ๋ก๊ทธ์ธ ์งํ์ด ๋ถ๊ฐํจ → ํ์ ์ ๋ณด๋ก ์์์ ํจ์ค์๋ ์์ฑ ํ ์ ์ฅ
SNS ํ์ ์ ๋ณด ๋ก๊ทธ์ธ
private fun createTokenWithMember(member: Member): TokenInfo {
val authorities = listOf(SimpleGrantedAuthority("ROLE_${member.type}"))
val principal = CustomUser(UUID.fromString(member.id.toString()), member.email, member.password, authorities)
val authenticationToken = PreAuthenticatedAuthenticationToken(principal, "", authorities)
val accessToken = tokenProvider.createAccessToken(authenticationToken)
val refreshToken = tokenProvider.createRefreshToken(authenticationToken)
return TokenInfo(accessToken = accessToken, refreshToken = refreshToken)
}
๐ก ์ด๋ฏธ ํ์ ๊ฐ์ ์ฌ๋ถ๊ฐ ์ธ์ฆ๋ ์ฌ์ฉ์์ authentication์ ๋ฐ๊ธํ๋ ๊ณผ์ ์ด๊ธฐ ๋๋ฌธ์,
์ผ๋ฐ ๋ก๊ทธ์ธ ๊ณผ์ ์ค ํ๋์ธ `authenticate()` ๋ก์ง์ ์งํํ ํ์๊ฐ ์์
- `authenticationToken` : ์ด๋ฏธ ์ธ์ฆ๋ ์ฌ์ฉ์์ ์ ๋ณด์ด๋ฏ๋ก PreAuthenticatedAuthenticationToken ์ ์์ฑ
์๋น์ค ์ ์ฒด ์ฝ๋
@Service
class SocialAuthService(
private val memberRepository: MemberRepository,
private val passwordEncoder: PasswordEncoder,
private val tokenProvider: TokenProvider,
private val socialClient: SocialClient
) {
fun signInWithSocial(token: String, type: String): TokenInfo {
val socialType = SocialType.from(type)
val socialUser = getSocialUserFromType(token, socialType)
val member = memberRepository.findByEmailAndSocialType(socialUser.email, socialType) ?: signUpWithSocial(
socialUser, socialType
)
return createTokenWithMember(member)
}
private fun getSocialUserFromType(token: String, type: SocialType): SocialUser {
return when (type) {
SocialType.KAKAO -> {
socialClient.getKakaoUser(token).run {
if (kakaoAccount == null) throw AuthenticationCredentialsNotFoundException("kakao ๊ณ์ ์ด ์กด์ฌํ์ง ์์ต๋๋ค.")
else SocialUser(
email = kakaoAccount.email, name = kakaoAccount.name
)
}
}
}
}
private fun signUpWithSocial(socialUser: SocialUser, socialType: SocialType): Member {
memberRepository.findByEmail(socialUser.email)?.let {
throw DuplicateKeyException("Already Exists")
}
val member = Member(
email = socialUser.email, password = passwordEncoder.encode(
listOf(
socialType, socialUser.email, socialUser.name
).joinToString("")
), name = socialUser.name, socialType = socialType
)
memberRepository.save(member)
return member
}
private fun createTokenWithMember(member: Member): TokenInfo {
val authorities = listOf(SimpleGrantedAuthority("ROLE_${member.type}"))
val principal = CustomUser(UUID.fromString(member.id.toString()), member.email, member.password, authorities)
val authenticationToken = PreAuthenticatedAuthenticationToken(principal, "", authorities)
val accessToken = tokenProvider.createAccessToken(authenticationToken)
val refreshToken = tokenProvider.createRefreshToken(authenticationToken)
return TokenInfo(accessToken = accessToken, refreshToken = refreshToken)
}
Social Client
@Component
class SocialClient(
private val webClient: WebClient
) {
// <https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info>
fun getKakaoUser(token: String): KakaoSocialUser = runBlocking {
webClient.post().apply {
uri("<https://kapi.kakao.com/v2/user/me>")
headers {
it.set("Authorization", "Bearer ${token}")
}
bodyValue("property_keys=[\\"kakao_account.name\\", \\"kakao_account.email\\"]")
}.retrieve().awaitBody()
}
}
- ์นด์นด์ค ์ ์ ์ ๋ณด ์กฐํ API (์ฃผ์)
- ํด๋ผ์ด์ธํธ๋ก๋ถํฐ ๋ฐ์ ํ ํฐ์ Header์ ์ ์ฅ
- bodyValue : ํ์ํ property ์ค์ ํ์ฌ ์๋ต์ผ๋ก ๋ฐ์ ์ ์์ (๊ฐ์ด๋)
- `runBlocking`
- `awaitBody`๋ coroutine ์ผ์ ์ค๋จ ํจ์๋ก ์ฝ๋ฃจํด ์์ญ์์๋ง ์คํ ๊ฐ๋ฅ
- ๋ค๋ง, ํ ํ๋ก์ ํธ์์๋ `suspend fun`(์ผ์ ์ค๋จ ํจ์)๋ฅผ ์ฌ์ฉํ์ง ์๋ ๊ตฌ์กฐ์ด๊ธฐ ๋๋ฌธ์
`coroutineScope`์ ์ด์ด์ฃผ๊ธฐ ์ํ `runBlocking`์ ์ฌ์ฉ
๊ฒฐ๊ณผ
- ์ด๋ ๊ฒ ๊ฐ์ ๋ SNS ์ ์ ๋ ํด๋น SNS ๋ก๊ทธ์ธ์ ํตํด์๋ง ํ์ ๋ก๊ทธ์ธ ๊ฐ๋ฅ
- ์ ์ ๊ฐ Email ๋ฐ Password(์์๋ก ๋ง๋ pw๋ฅผ ์ ํํ) ์ ๋ ฅํ์ฌ ๋ก๊ทธ์ธ ์๋ํ ๊ฒฝ์ฐ์๋ ๋ก๊ทธ์ธ ๋ถ๊ฐ
๊ฐ์ Task
- ๊ธฐ์กด ํ์๊ณผ SNS ํ์์ ์ฐ๋ → ์ฌ๋ฌ ๊ฒฝ๋ก๋ก ๋ก๊ทธ์ธ์ ํ ์ ์๋๋ก ์ง์
โ๏ธ์ฝ๋๋ ์๋์์โ๏ธ
https://github.com/jeongum/spring-security-kotlin
GitHub - jeongum/spring-security-kotlin
Contribute to jeongum/spring-security-kotlin development by creating an account on GitHub.
github.com