(์ ์์ ๋ฐฑ์๋ ๊ฐ๋ฐ์๊ธฐ ๋๋ฌธ์ React๋ ๋ชปํฉ๋๋ค.
React ์ฝ๋๋ Chat GPT ์์ผ์ ๊ตฌํํ์๊ณ , ๋์ ๋ฐฑ์๋์ ์จ ์ง์ฌ์ ๋ดํ์ ๊ตฌํํ์์ผ๋, ํ๋ก ํธ ์ฝ๋๋ ์ ๋ง ๊ทธ๋ฅ ํ ์คํธ์ฉ์ผ๋ก๋ง ์ฐธ๊ณ ํด ์ฃผ์๋ฉด ๊ฐ์ฌํ๊ฒ ์ต๋๋ค.)
OAuth์ ๊ฐ๋ ์ ๋ํด์๋ ์ด๋ฏธ ๋ง์ ๋ธ๋ก๊ทธ๋ค์์ ๋ค๋ฃจ๊ณ ์์ผ๋ฏ๋ก ์ ๋ ์ด์ ๋ํด์๋ ๋ฐ๋ก ๋ค๋ฃจ์ง ์๊ฒ ์ต๋๋ค.
์ด ๊ธ์์๋ (์ ์ด๋ ์ ๊ฐ ์ดํด๋ณด์๋) ๋๋ถ๋ถ์ ๋ธ๋ก๊ทธ์์ ์ ๋งคํ๊ฒ ์ค๋ช ๋์ด์๋ ํ๋ก ํธ์๋์ ๋ฐฑ์๋์ ์ญํ ๊ณผ, ์ค์ ๋ก ์ด๋ป๊ฒ ๊ตฌํํ๋์ง๋ฅผ ์ง์ค์ ์ผ๋ก ๋ค๋ค๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
๋ํ ์ด ๊ณผ์ ์์ ๋จ์ํ ๊ตฌํํ๊ณ ๋๋ด๋ ๊ฒ์ด ์๋๋ผ ์๋ก์ด ํ์ฌ ํ๋ซํผ์ ์ถ๊ฐํ๋ ๊ณผ์ ๋ ๊ฐ๋จํ๊ฒ ์ฒ๋ฆฌํ ์ ์๋๋ก ์ ์ฐํ๊ณ ํ์ฅ ๊ฐ๋ฅํ ๊ตฌ์กฐ๋ฅผ ๊ฐ์ ธ๊ฐ๋ ์ฝ๋๋ฅผ ์์ฑํด๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
์์ค ์ฝ๋๋ ์๋ Repository์์ ํ์ธํ์ค ์ ์์ต๋๋ค.
https://github.com/Mallang-Study/oauth-login-sample
๐ง OAuth 2.0 ํ๋ก์ฐ ์ ๋ฆฌ
์ฒ์ OAuth 2.0์ ์ ํ๊ณ ๊ณต๋ถํ๋ค ๋ณด๋ฉด ๋ค์๊ณผ ๊ฐ์ ๊ทธ๋ฆผ๊ณผ ์ค๋ช ์ ํํ๊ฒ ๋ง์ฃผํ ์ ์์ต๋๋ค.
์ ์ค๋ช ์ ํ๋ก ํธ์๋๊ณผ ๋ฐฑ์๋์ ์ญํ ์ด ๋ถ๋ถ๋ช ํ๊ฒ ๋์์๋ค๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.
์ ๊ฐ๋ ์ ์ฒ์ ์ ํ๊ณ ๊ตฌํํ๋ ค๋ ์ฌ๋๋ค์ ๋๋ถ๋ถ ํ๋ก ํธ์๋ ๊ฐ๋ฐ์์ ๋ฐฑ์๋ ๊ฐ๋ฐ์์ผํ ๋ฐ, ์ด๋ค์ ์ญํ ์ ์ด๋ป๊ฒ ๊ตฌ๋ถ๋์ด์ง๊น์?
OAuth2.0 ์ ๋ํด ์ค๋ช ํ๋ ๊ธ๋ค์ ์ฐพ์๋ณด๋ฉด, ์ด๋ค์ ์ญํ ์ ๋จ์ํ Client๋ก๋ง ๋ญ๋ฑ๊ทธ๋ ค ์ค๋ช ํ๋ ๊ธ๋ค์ด ๋๋ถ๋ถ์ด์์ต๋๋ค.
์ด๋ฌ๋ฉด ์ ๊ฐ์ ๋ฐ๋ณด ๊ฐ๋ฐ์ ์ ์ฅ์์๋ ๊ตฌํํ๊ธฐ๊ฐ ์ฝ์ง ์๊ฒ ์ฃ ... ๐ข
๊ทธ๋์ ์ ๋ ํ๋ก ํธ์๋ ๊ฐ๋ฐ์์ ๋ฐฑ์๋ ๊ฐ๋ฐ์์ ์ญํ ์ ์กฐ๊ธ ๋ ๊ตฌ๋ถ์ง์ด Client์ ์ญํ ์ ์ง์คํ๊ณ ์ ํฉ๋๋ค.
๐ง ๊ตฌํํ๋ ๋ฐฉ๋ฒ
OAuth2.0 ์ ํตํ ๋ก๊ทธ์ธ์ ๊ตฌํํ๋ ๋ฐฉ๋ฒ์ ํฌ๊ฒ Redirect URI๋ฅผ ๋ฐฑ์๋๋ก ์ค์ ํ๋ ๋ฐฉ๋ฒ๊ณผ,ํ๋ก ํธ์๋๋ก ์ค์ ํ๋ ๋ฐฉ๋ฒ์ผ๋ก ๋๋ฉ๋๋ค.
๊ฐ๊ฐ์ ๋ฐฉ์์ผ๋ก ๊ตฌํํ์ ๋ ์ฅ์ ๊ณผ ํ๊ณ์ ์ ๋ํด ์ค๋ช ํ๊ณ , ์ด๋ค ์ค ํ๋ก ํธ์๋๋ก Redirect URI๋ฅผ ์ค์ ํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ณด๊ฒ ์ต๋๋ค.
(์ด๋ ๋ชจ๋ ์ํฉ์์ ํ์๊ฐ์ & ๋ก๊ทธ์ธ์ ํ๋ค๋ ๊ฐ์ ์ ํตํด ์ฅ์ ๊ณผ ๋จ์ ์ ์์๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค. ํ์๊ฐ์ ํ๋ก์ฐ๋ ์ ๊ทธ๋ฆผ๊ณผ ๋์ผํฉ๋๋ค.)
๐ณ Redirect URI๋ฅผ ๋ฐฑ์๋๋ก ์ค์ ํ๊ธฐ
ํฐ ํ๋ฆ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
1. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ ๋ฒํผ์ ํด๋ฆญํ๋ค.
2. (๋ฒํผ์ ๋๋ฅด๋ฉด) ํ๋ก ํธ์๋๋ ๋ฐฑ์๋์ ํน์ URI๋ก ์์ฒญ์ ๋ณด๋ธ๋ค.
3. ๋ฐฑ์๋๋ ์ด๋ฅผ ์ฒ๋ฆฌํ์ฌ cliend_id, redirect_uri ๋ฑ์ ํฌํจํ์ฌ ์นด์นด์ค ์ธ์ฆ ์๋ฒ(Authorization Server)์ Auth Code ๋ฐ๊ธ URL๋ก Redirect ์ํจ๋ค.
์ด๋ ๊ฒ ๋๋ฉด ์นด์นด์ค ์ธ์ฆ ์๋ฒ์์ ์ฌ์ฉ์์๊ฒ ๋ก๊ทธ์ธ ํ์ด์ง๋ฅผ ์ ๊ณตํ๋ค.
4. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ์งํํ๊ณ , ์ ๋ณด ์ฌ์ฉ์ ๋์ํ๋ค.
5. ํด๋น ์ ๋ณด๋ Authorization Server์ ์ ๋ฌ๋๋ฉฐ, ์ดํ ์ฌ์ ์ ๋ฑ๋กํ ๋ฐฑ์๋์ Redirect URI๋ก Auth Code์ ํจ๊ป Redirected ๋๋ค.
6. ๋ฐฑ์๋๋ @GetMapping ๋ฑ์ผ๋ก Redirect URI๋ก ๋ค์ด์ค๋ ์์ฒญ์ ์ฒ๋ฆฌํ๋๋ก ๊ตฌํํ๋ค.
- ์์ฒญ์ Query String์ผ๋ก๋ถํฐ Code๋ฅผ ์ถ์ถํ๋ค.
- ํด๋น Code๋ฅผ ๊ฐ์ง๊ณ AccessToken์ ๋ฐ์์จ๋ค.
- ํด๋น AccessToken์ ํตํด ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ฐ์์ ํ์๊ฐ์ &๋ก๊ทธ์ธ ์ํจ๋ค.
- ๋ก๊ทธ์ธ ์ดํ ๋ฐ๊ธ๋ ์ธ์ฆ ์ ๋ณด(session ํน์ token)์ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ์ถ๊ฐํ์ฌ ํ๋ก ํธ์๋์ ๋ก๊ทธ์ธ ์ฑ๊ณต์ ์ฒ๋ฆฌํ๋ URL๋ก Redirect ์ํจ๋ค.
Redirect URI๋ฅผ ๋ฐฑ์๋๋ก ์ค์ ํ๋ฉด, ํ๋ก ํธ๋ ์๋ฌด๋ฐ ์ฒ๋ฆฌ๋ฅผ ํ์ง ์์๋ ๋ฉ๋๋ค.
๋ฐฑ์๋ ๋ด๋ถ์์ Auth Code๋ฅผ ํตํด AccessToken์ ๊ฐ์ ธ์ค๊ณ , ์ด์ด์ AccessToken์ ํตํด ์ฌ์ฉ์์ ์ ๋ณด๊น์ง ๊ฐ์ ธ์จ ๋ค ํ์๊ฐ์ & ๋ก๊ทธ์ธ์ ์งํํ๊ฒ ๋ฉ๋๋ค.
๊ทธ๋ฌ๋ ๋ก๊ทธ์ธ ์ฑ๊ณต ์ดํ Header๋ Body์ token ๋ฑ์ ์ ๋ณด๋ฅผ ๋ฃ์ด์ฃผ๋ ๊ฒฝ์ฐ CORS ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ฌ ์ ๋ฌํ ์ ์๋ ์ํฉ์ด ๋ฐ์ํฉ๋๋ค.
๊ทธ๋์ ์ด ๊ฒฝ์ฐ, ํ์๊ฐ์ ํน์ ๋ก๊ทธ์ธ ์ฒ๋ฆฌ ์๋ฃ ์ ์์ฑ๋๋ ์ธ์ฆ ์ ๋ณด(JWT ํน์ ์ธ์ ๋ฑ)์ ํ๋ก ํธ์๋์ ์ ๋ฌํ๊ธฐ ์ํด์๋ ์ค์ง Query String์ ํตํด ๋ค์๊ณผ ๊ฐ์ ํํ๋ก ์ ๋ฌํ๋ ๋ฐฉ๋ฒ๋ฐ์ ์์ต๋๋ค.
[๋ก๊ทธ์ธ ์ดํ Redirectํ ํ๋ก ํธ์๋ URL]?accessToken={๋ก๊ทธ์ธ ํ ๋ฐ๊ธํ ์ก์ธ์ค ํ ํฐ(or ์ธ์ ID)}
๋ํ Redirect๋ฅผ ์ฌ์ฉํ ์ ์๋ ์๋๋ก์ด๋์ ๊ฐ์ ๋ชจ๋ฐ์ผ ํ๊ฒฝ์ ๊ฒฝ์ฐ ์ด ๋ฐฉ๋ฒ์ผ๋ก๋ ํ์๊ฐ์ ๊ณผ ๋ก๊ทธ์ธ์ ์งํํ ์ ์๋ค๋ ๊ฒ์ ๋๋ค.
๊ทธ๋์ ์ด๋ฌํ ๊ฒฝ์ฐ๋ผ๋ฉด ๋ฐ๋์ Redirect URI๋ฅผ ํ๋ก ํธ์๋๋ก ์ค์ ํ๊ณ ์ฒ๋ฆฌํ์ฌ์ผ ํฉ๋๋ค.
์ฆ ์ด ๋ฐฉ๋ฒ์ ํน์ง์ ์ ๋ฆฌํ๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ์ธ์ฆ ์ ๋ณด(ํ ํฐ, ์ธ์ )์ Query String์ผ๋ก ์ ๋ฌํ๋ ๋ฐฉ๋ฒ๋ฐ์ ์๋ค.
- ์๋๋ก์ด๋์ ๊ฐ์ด Redirect ํ ์ ์๋ ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์๋ ์ฌ์ฉํ ์ ์๋ค.
์ง์ง ๋จ์ ๋ฐ์ ์๋ค์....
์ด์ Redirect URI๋ฅผ ํ๋ก ํธ์๋๋ก ์ค์ ํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์์๋ณด๊ฒ ์ต๋๋ค.
๐ณ Redirect URI๋ฅผ ํ๋ก ํธ์๋๋ก ์ค์ ํ๊ธฐ
์ด ๋ฐฉ๋ฒ์ ์ฌ์ฉํ๋ ํ๋ก์ฐ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
(์ด๋ ์น ํ๋ก ํธ์๋ ๊ธฐ์ค์ผ๋ก ์๋๋ก์ด๋ ๋ฑ ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์๋ ์๋ง ์กฐ๊ธ ํ๋ก์ฐ๊ฐ ๋ค๋ฅผ ๊ฒ์ธ๋ฐ,
๋ชจ๋ฐ์ผ์ ๊ฒฝ์ฐ์๋ accessToken๊น์ง ๋ฐ์์ค๋ ๊ฒ์ ํ๋ก ํธ(๋ชจ๋ฐ์ผ)์์ ์งํํ๋ ์์ผ๋ก ๊ตฌํ๋๋ค๊ณ ์๊ณ ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ ๊ฒฝ์ฐ์๋ redirect url์ ํ๋ก ํธ์๋๋ก ์ค์ ํด์ฃผ์ด์ผ ํฉ๋๋ค.)
1. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ ๋ฒํผ์ ํด๋ฆญํ๋ค.
2. (๋ฒํผ์ ๋๋ฅด๋ฉด) ํ๋ก ํธ์๋๋ ๋ฐฑ์๋์ ํน์ URI๋ก ์์ฒญ์ ๋ณด๋ธ๋ค.
3. ๋ฐฑ์๋๋ ์ด๋ฅผ ์ฒ๋ฆฌํ์ฌ cliend_id, redirect_uri ๋ฑ์ ํฌํจํ์ฌ ์นด์นด์ค ์ธ์ฆ ์๋ฒ(Authorization Server)์ Auth Code ๋ฐ๊ธ URL๋ก Redirect ์ํจ๋ค.
์ด๋ ๊ฒ ๋๋ฉด ์นด์นด์ค ์ธ์ฆ ์๋ฒ์์ ์ฌ์ฉ์์๊ฒ ๋ก๊ทธ์ธ ํ์ด์ง๋ฅผ ์ ๊ณตํ๋ค.
4. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธ์ ์งํํ๊ณ , ์ ๋ณด ์ฌ์ฉ์ ๋์ํ๋ค. --- ์ฌ๊ธฐ๊น์ง ๋์ผ
5. ํด๋น ์ ๋ณด๋ Authorization Server์ ์ ๋ฌ๋๋ฉฐ, ์ดํ ์ฌ์ ์ ๋ฑ๋กํ ํ๋ก ํธ์๋์ Redirect URI๋ก Auth Code์ ํจ๊ป Redirected ๋๋ค.
6. ํ๋ก ๋์๋๋ ํด๋น Redirect URI๋ก ์์ฒญ์ด ๋ค์ด์ค๋ฉด, Query String์์ code๋ฅผ ์ถ์ถํ์ฌ ๋ฐฑ์๋์ code๋ฅผ ํตํด ํ์๊ฐ์ & ๋ก๊ทธ์ธ์ ์งํํ๋ API๋ก ์์ฒญ์ ์ ์กํ๋ค.
7. ๋ฐฑ์๋๋ @PostMapping(Get๋ ์๊ด์๊ณ ๋ญ ์๋ฌดํผ) ๋ฑ์ผ๋ก ํด๋น ์์ฒญ์ ์ฒ๋ฆฌ(code๋ฅผ ํตํด ํ์๊ฐ์ & ๋ก๊ทธ์ธ)ํ๋ค.
- ์์ฒญ์ Query String์ผ๋ก๋ถํฐ Code๋ฅผ ์ถ์ถํ๋ค.
- ํด๋น Code๋ฅผ ๊ฐ์ง๊ณ AccessToken์ ๋ฐ์์จ๋ค.
- ํด๋น AccessToken์ ํตํด ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ฐ์์ ํ์๊ฐ์ &๋ก๊ทธ์ธ ์ํจ๋ค.
- ๋ก๊ทธ์ธ ์ดํ ๋ฐ๊ธ๋ ์ธ์ฆ ์ ๋ณด(session ํน์ token)์ Header, Cookie, Body ๋ฑ์ ๋ด์ ํ๋ก ํธ์๋์ ๋ฐํํ๋ค.
์ ๋ฐฉ๋ฒ์ Redirect URI๋ฅผ ๋ฐฑ์๋๋ก ์ค์ ํ์ ๋์ ๋จ์ ์ ๋ชจ๋ ํด๊ฒฐํฉ๋๋ค.
์ฆ ์ด ๋ฐฉ๋ฒ์ ํน์ง์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ์ธ์ฆ ์ ๋ณด(ํ ํฐ, ์ธ์ )์ ์ฌ๋ฌ ๋ฐฉ๋ฒ(Query String, Header, Body)์ผ๋ก ์ ๋ฌํ ์ ์๋ค.
์ด์ ๋ถํฐ ํด๋น ๋ฐฉ๋ฒ์ ํตํด ํ์๊ฐ์ & ๋ก๊ทธ์ธ์ ์งํํ๋ ์ฝ๋๋ฅผ ์์ฑํด ๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
์ฐ์ ์ ์ผ ์ฌ์ด ์นด์นด์ค๋ถํฐ ์์ํ๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ง ์นด์นด์ค Developers์ ์ ํ๋ฆฌ์ผ์ด์ ๋ฑ๋ก
Kakao Developers(https://developers.kakao.com/)์ ์ ์ํฉ๋๋ค.
๋ด ์ ํ๋ฆฌ์ผ์ด์ ์ ํด๋ฆญํฉ๋๋ค.
์ ํ๋ฆฌ์ผ์ด์ ์ถ๊ฐํ๊ธฐ๋ฅผ ํด๋ฆญํฉ๋๋ค.
์ฑ ์ด๋ฆ๊ณผ ์ฌ์ ์๋ช ์ ์ ๋ ฅํฉ๋๋ค.
์ฑ ์ค์ - ์์ฝ ์ ๋ณด ํ๋ฉด์ ๋ณด์ด๋ REST API ํค ๋ฅผ ์ด๋์๊ฐ ์ ์ด๋์ธ์!
ํด๋น ํค๋ฅผ client_id๋ผ๊ณ ๋ถ๋ฆ ๋๋ค.
์ ๋ ์ด์ ๋ถํฐ ์ด๋ฅผ mallang-kakao-client-id ๋ก ๊ฐ์ ํ๊ณ ์งํํ๊ฒ ์ต๋๋ค.
(์๊ฐํด๋ณด๋ ์ด์ฐจํผ ๋ ธ์ถ๋๋ ๊ฐ์ด๋ผ ๊ตณ์ด ๊ฐ๋ฆด ํ์๋ ์์๋ค์ฉ..)
์ ํ ์ค์ - ์นด์นด์ค ๋ก๊ทธ์ธ์ผ๋ก ์ด๋ํ์ฌ ํ์ฑํ ์ํ๋ฅผ OFF ์์ ON์ผ๋ก ๋ฐ๊ฟ์ค๋๋ค.
์ดํ Redirect URI ๋ฑ๋ก ๋ฒํผ์ ํด๋ฆญํด์ฃผ์ธ์.
Redirect URI๋ฅผ ํ๋ก ํธ์๋๋ก ์ค์ ํ ๊ฒ์ด๋ฏ๋ก, auth code๋ฅผ ํตํด ๋ฐฑ์๋๋ก ๋ก๊ทธ์ธ ์์ฒญ์ ๋ณด๋ผ ํ๋ก ํธ์๋์ URI๋ฅผ ์ ๋ ฅํด์ฃผ์ธ์.
๊ทธ๋ฅ ์ ๋ฐ๋ผ http://localhost:3000/oauth/redirected/kakao ๋ฅผ ์ค์ ํ๋ ๊ฒ์ด, ์์ผ๋ก ์ฝ๋ ์งํ์ ๋ฐ๋ผ์ค๋ ๊ฒ์ ์์ด์ ์ ์ผ ์ฝ๊ธฐ ๋๋ฌธ์, ์ผ๋จ ๋ฐ๋ผ ํ์๋ ๊ฒ์ ์ถ์ฒํฉ๋๋ค.
์นด์นด์ค ๋ก๊ทธ์ธ - ๋ณด์์ผ๋ก ์ด๋ํ์ฌ Client Secret์ ์์ฑํด์ค๋๋ค.
์์ฑ๋ Client Secret์ ์ด๋๊ฐ์ ์ ์ ์ด ๋์๊ณ , ํ์ฑํ ์ํ๋ฅผ ์ฌ์ฉ์ํจ์์ ์ฌ์ฉํจ์ผ๋ก ๋ณ๊ฒฝํด์ฃผ์ธ์.
์ ๋ ์ด์ ๋ถํฐ ์ด๋ฅผ mallang-kakao-secret-key ๋ก ๋ถ๋ฅด๋๋ก ํ๊ฒ ์ต๋๋ค.
์นด์นด์ค ๋ก๊ทธ์ธ - ๋์ํญ๋ชฉ์ผ๋ก ์ด๋ํฉ๋๋ค.
์ด๊ณณ์์๋ ์ฌ์ฉํ ์ฌ์ฉ์์ ์ ๋ณด๋ฅผ ์ค์ ํด์ฃผ๋ฉด ๋๋๋ฐ์, ์ด๋ฒ ์ค์ต์์๋ ๋๋ค์(profile_nickname)๊ณผ ํ๋กํ ์ฌ์ง(profile_image)๋ง์ ์ค์ ํ๋๋ก ํ๊ฒ ์ต๋๋ค.
์์ ๊ฐ์ด ํ์ ๋์๋ฅผ ํด๋ฆญํ ๋ค ์ ์ฅํด์ฃผ์ธ์.
์ฌ๊ธฐ๊น์ง ํ๋ฉด ๊ธฐ๋ณธ์ ์ธ ์ค์ ์ด ๋๋ฌ๊ณ , ์ฌ๊ธฐ์ ์์ฑํ ์ ๋ณด๋ค์ ์ ๋ฆฌํ๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
REST API ํค(client_id) : mallang-kakao-client-id
Client Secret : mallang-kakao-secret-key
Redirect URI : http://localhost:3000/oauth/redirected/kakao
๋์ ํญ๋ชฉ : ๋๋ค์(profile_nickname), ํ๋กํ ์ฌ์ง(profile_image)
์ด์ ํ๋ก ํธ์๋ ์ฝ๋๋ฅผ ์์ฑํ๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ง ํ๋ก ํธ์๋ ์ฝ๋ ์์ฑ (React)
ํ์ผ์ index.js, App.js, KakaoRedirectPage.js, LoginPage.js 4๊ฐ์ ๋๋ค.
ํ๋ก ํธ์๋ ํจํค์ง๋ฅผ ์ด๋ป๊ฒ ๋๋ ์ผ ํ ์ง ๋ชฐ๋ผ์ ๊ทธ๋ฅ ํ๋์ ๋๋ ค๋ฐ์์ต๋๋ค.
(์ด์ฌํ ์ฝ๋ ๋ง๋๋ GPT)
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<App />
);
์ด๊ฑด ๋ฉ์ธ ํ์ด์ง์ ๋๋ค.
App.js
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoginPage from "./LoginPage";
import KakaoRedirectPage from "./KakaoRedirectPage";
const App = () => {
return (
<div className='App'>
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginPage />}></Route>
<Route path="/oauth/redirected/kakao" element={<KakaoRedirectPage />}></Route>
</Routes>
</BrowserRouter>
</div>
);
};
export default App;
๋ผ์ฐํฐ๋ฅผ ํตํด์ /๋ก ๋ค์ด์ค๋ ์์ฒญ์ ๋ก๊ทธ์ธ ํ์ด์ง๋ฅผ ๋ณด์ฌ์ฃผ๊ณ ,
/oauth/redirected/kakao๋ก ๋ค์ด์ค๋ ์์ฒญ์ KakaoRedirectPage๋ฅผ ๋ณด์ฌ์ค๋๋ค.
์ด๋ /oauth/redirected/kakao ๋ ์นด์นด์ค ๋ก๊ทธ์ธ API์ Redirect URI๋ก ์ค์ ํ URI์ ๋๋ค.
(์ ๋ Redirect URI๋ฅผ http://localhost:3000/oauth/redirected/kakao๋ก ์ค์ ํ์์ต๋๋ค.)
LoginPage.js
import React from 'react';
const LoginPage = () => {
const handleButtonClick = () => {
window.location.href = 'http://localhost:8080/oauth/kakao';
};
return (
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh'}}>
<button
onClick={handleButtonClick}
style={{padding: '10px 20px', fontSize: '18px', borderRadius: '5px', cursor: 'pointer'}}
>
์นด์นด์คํก ๋ก๊ทธ์ธ
</button>
</div>
);
};
export default LoginPage;
๊ทธ๋ฅ ์นด์นด์คํก ๋ก๊ทธ์ธ ๋ฒํผ ํ๋ ์๋ ํ์ด์ง์ ๋๋ค.
๋ก๊ทธ์ธ ๋ฒํผ์ ๋๋ฅด๋ฉด http://localhost:8080/oauth/kakao๋ก ์ด๋ํ๊ฒ ๋๋๋ฐ์, ์ด๋ ๋ฐฑ์๋์์ ์ฒ๋ฆฌํ๋ฉฐ cliend_id, redirect_uri ๋ฑ์ ํฌํจํ์ฌ ์นด์นด์ค ๋ก๊ทธ์ธ ํ์ด์ง๋ก Redirect ์์ผ์ค๋๋ค.
KakaoRedirectPage.js
import React, {useEffect} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import axios from 'axios';
const KakaoRedirectPage = () => {
const location = useLocation();
const navigate = useNavigate();
const handleOAuthKakao = async (code) => {
try {
// ์นด์นด์ค๋ก๋ถํฐ ๋ฐ์์จ code๋ฅผ ์๋ฒ์ ์ ๋ฌํ์ฌ ์นด์นด์ค๋ก ํ์๊ฐ์
& ๋ก๊ทธ์ธํ๋ค
const response = await axios.get(`http://localhost:8080/oauth/login/kakao?code=${code}`);
const data = response.data; // ์๋ต ๋ฐ์ดํฐ
alert("๋ก๊ทธ์ธ ์ฑ๊ณต: " + data)
navigate("/success");
} catch (error) {
navigate("/fail");
}
};
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const code = searchParams.get('code'); // ์นด์นด์ค๋ Redirect ์ํค๋ฉด์ code๋ฅผ ์ฟผ๋ฆฌ ์คํธ๋ง์ผ๋ก ์ค๋ค.
if (code) {
alert("CODE = " + code)
handleOAuthKakao(code);
}
}, [location]);
return (
<div>
<div>Processing...</div>
</div>
);
};
export default KakaoRedirectPage;
ํด๋น ํ์ด์ง๋ App.js์ ์ํด /oauth/redirected/kakao๋ก ์์ฒญ์ด ๋ค์ด์ค๋ฉด ๋ณด์ฌ์ง ํ๋ฉด์ ๋๋ค.
ํด๋น ํ๋ฉด์ ๋ณด์ฌ์ง์๋ง์ http://localhost:8080/oauth/login/kakao๋ก ์์ฒญ์ ๋ณด๋ ๋๋ค.
์ด๋ ์นด์นด์ค ์ธ์ฆ ์๋ฒ์ ์ํด ๋ฆฌ๋ค์ด๋ ํธ๋๋ ํ์ด์ง๋ก, ๋ฆฌ๋ค์ด๋ ํธ ๋๋ ๋์์ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก code๊ฐ ํจ๊ป ์ ๋ฌ๋๋๋ฐ์, ์ด๋ฅผ ์ถ์ถํ์ฌ ๋ฐฑ์๋ ์๋ฒ์ ํจ๊ป ์ ๋ฌํฉ๋๋ค.
์๋๋ AccessToken๋ฑ์ ๋ฐฑ์๋ ์๋ฒ๋ก๋ถํฐ ๋ฐ๊ฒ ์ง๋ง, ์ฌ๊ธฐ์๋ ์ฝ๋๊ฐ ๋๋ฌด ๊ธธ์ด์ง๊ธฐ๋๋ฌธ์ ๋จ์ Id๋ฅผ ๋ฐํํ๋ ๊ฒ์ผ๋ก ํํํ๊ฒ ์ต๋๋ค.
๊ตฌ์ฒด์ ์ธ ๋์ ๊ณผ์ ์ ๋ฐฑ์๋ ์ฝ๋๊น์ง ์์ฑํ ๋ค ์ดํด๋ณด๊ฒ ์ต๋๋ค.
๐ง ๋ฐฑ์๋ ์ฝ๋ ์์ฑ (Spring)
์ด๊ธฐ ์ค์ ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
๐ณ build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.2'
id 'io.spring.dependency-management' version '1.1.2'
}
group = 'mallang'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
// Http Interface
implementation 'org.springframework.boot:spring-boot-starter-webflux'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
// FOR @ConfigurationProperties
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.0'
}
tasks.named('test') {
useJUnitPlatform()
}
// FOR @ConfigurationProperties
tasks.named('compileJava') {
inputs.files(tasks.named('processResources'))
}
๋ณ๋ค๋ฅธ ์ค๋ช ์์ด ๋์ด๊ฐ๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ณ application.yml
logging:
level:
org.hibernate.orm.jdbc.bind: TRACE
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
highlight_sql: true
hibernate:
ddl-auto: create
์ด๊ฒ๋ ๋ณ๋ค๋ฅธ ์ค๋ช ์์ด ๋์ด๊ฐ๋๋ก ํ๊ฒ ์ต๋๋ค.
๋์ฒด๋ก ๋ก๊ทธ๋ฅผ ๋จ๊ธฐ๊ธฐ ์ํ ์ค์ ์ ๋๋ค.
์ด๊ธฐ ์ค์ ์ ๋๋ฌ๊ณ , ์ด์ ๋ถํฐ ์ฝ๋๋ฅผ ์์ฑํ ๊ฒ์ธ๋ฐ์, ์ฒ์์๋ ๊ฐ๋จํ ๊ตฌํํ ๋ค ๋ฆฌํฉํ ๋ง ํ๋ ๊ณผ์ ์ ๋ณด์ฌ๋๋ฆด๊น ์ถ์์ง๋ง ๋๋ฌด ๋ฃจ์ฆํด์ง ์ ์์ ๊ฒ ๊ฐ์์, ์กฐ๊ธ ๋ณต์กํ๋๋ผ๋ ์ต์ข ํํ์ ์ฝ๋๋ฅผ ๋ณด์ฌ๋๋ฆฌ๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ณ ๊ตฌํ ์์
๊ตฌํ ์์๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
1. ์ฌ์ฉ์๊ฐ ํ๋ก ํธ์๋๋ฅผ ํตํด http://localhost:8080/oauth/kakao๋ก ์ ์ํ๋ฉด ์นด์นด์คํก ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก Redirect ์ํค๋ ๊ณผ์ ์ ๊ตฌํํ๋ค.
์ฌ์ฉ์๊ฐ ์นด์นด์คํก ๋ก๊ทธ์ธ & ์ ๋ณด ์ ๊ณต ๋์๋ฅผ ์งํํ ์ดํ ์นด์นด์คํก ์ธ์ฆ ์๋ฒ์์ ํ๋ก ํธ์๋๋ก Auth Code๋ฅผ ํฌํจํ์ฌ Redirect ์ํจ๋ค.
ํ๋ก ํธ์๋๋ Redirect ๋๋ ์๊ฐ Auth Code๋ฅผ ๊บผ๋ด์ http://localhost:8080/oauth/login/kakao?code={๋ฐ์ Auth Code}๋ก POST ์์ฒญ์ ๋ณด๋ธ๋ค.
2. /oauth/login/kakao๋ก auth code์ ํจ๊ป POST ์์ฒญ์ด ๋์ฐฉํ๋ฉด ๋ฐฑ์๋๋ ๋ฐ์ auth code์ ํจ๊ป ์นด์นด์ค ์ธ์ฆ ์๋ฒ์ AccessToken์ ์์ฒญํ์ฌ ๋ฐ๊ธ๋ฐ๋๋ค.
3. ๋ฐ์์จ AccessToken์ ๊ฐ์ง๊ณ ์นด์นด์ค์ ๋ฑ๋ก๋ ํ์ ์ ๋ณด(๋๋ค์, ํ๋กํ ์ฌ์ง)๋ฅผ ์กฐํํ๋ค.
4. ๋ฐ์์จ ์ ๋ณด๋ฅผ ํตํด ๋ก๊ทธ์ธ์ ์งํํ๋ค. ์ด๋ ํ์๊ฐ์ ๋์ด์์ง ์๋ค๋ฉด ํ์๊ฐ์ ๋ ํจ๊ป ์งํํ๋ค.
5. /oauth/login/kakao๋ก ์์ฒญ์ด ๋ค์ด์ค๋ฉด 2~4์ ๊ณผ์ ์ ํตํด ๋ก๊ทธ์ธ์ ์งํํ ๋ค, AccessToken(์๋ ๋ฐฑ์๋๊ฐ ์ธ์ฆ์ ์ํด ๋ฐ๊ธํด์ฃผ๋์ )์ ์์ฑํ๊ณ , ์ด๋ฅผ ๋ฉ์ธ์ง ๋ณธ๋ฌธ์ ๋ด๋๋ค.
์ ๊ณผ์ ์ ์งํํ๋ฉฐ ํ์ฅ์ ์ ์ฐํ ์ฝ๋๋ฅผ ์์ฑํ๊ณ , ์ดํ Naver๋ ์ถ๊ฐํด๋ณด๋ฉฐ ์ผ๋ง๋ ๊ฐ๋จํ๊ฒ ์ถ๊ฐํ ์ ์๋์ง๋ฅผ ์ดํด๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ณ ์ต์ข ๊ตฌ์กฐ ๋ฏธ๋ฆฌ ์ดํด๋ณด๊ธฐ
๐ง 1. /oauth/kakao์ผ๋ก ์ ์ํ๋ฉด ์นด์นด์คํก ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก Redirect ์ํจ๋ค.
โจ OauthServerType
package mallang.oauth.domain;
import static java.util.Locale.ENGLISH;
public enum OauthServerType {
KAKAO,
;
public static OauthServerType fromName(String type) {
return OauthServerType.valueOf(type.toUpperCase(ENGLISH));
}
}
์ด๋ ์นด์นด์ค, ๊ตฌ๊ธ, ๋ค์ด๋ฒ ๋ฑ Oauth2.0 ์ธ์ฆ์ ์ ๊ณตํ๋ ์๋ฒ์ ์ข ๋ฅ๋ฅผ ๋ช ์ํ Enum์ ๋๋ค.
"kakao"๋ฅผ ํตํด OauthServerType.KAKAO๋ฅผ ์ฐพ์์ค๊ธฐ ์ํด fromName() ์ด๋ผ๋ ๋ฉ์๋๋ฅผ ๊ตฌํํ์์ต๋๋ค.
์ด์ OauthServerType๋ณ๋ก ํด๋น ์๋น์ค์ Auth Code๋ฅผ ๋ฐ์์ค๋ URL์ ์์ฑํด์ฃผ๋ ๊ธฐ๋ฅ์ ์์ฑํ๋๋ก ํ๊ฒ ์ต๋๋ค.
โจ AuthCodeRequestUrlProvider
package mallang.oauth.domain.authcode;
import mallang.oauth.domain.OauthServerType;
public interface AuthCodeRequestUrlProvider {
OauthServerType supportServer();
String provide();
}
์ด๋ ์ธํฐํ์ด์ค๋ก, AuthCode๋ฅผ ๋ฐ๊ธํ URL์ ์ ๊ณตํ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค.
supportServer()๋ ์์ ์ด ์ด๋ค OauthServerType๋ฅผ ์ง์ํ ์ ์๋์ง๋ฅผ ๋ํํฉ๋๋ค.
์๋ฅผ ๋ค์ด KakaoAuthCodeRequestUrlProvider๋ OauthServerType์ผ๋ก KAKAO๋ฅผ ๋ฐํํ ๊ฒ์ ๋๋ค.
provide()๋ฅผ ํตํด URL์ ์์ฑํ์ฌ ๋ฐํํ๋ฉฐ, ํด๋น ์ฃผ์๋ก Redirect ํ๋ค๋ฉด ๋ค์๊ณผ ๊ฐ์ ํ๋ฉด์ด ๋์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ง๊ธ์ Kakao ๋ฟ์ด์ง๋ง, ์ดํ Naver ๋ฑ ๋ค๋ฅธ ์๋น์ค๋ ์ถ๊ฐํ ๊ฒ์ด๊ธฐ ๋๋ฌธ์ ํด๋น ๊ธฐ๋ฅ์ ์ธํฐํ์ด์ค๋ก ์ถ์ํํ์์ต๋๋ค.
Kakao์์ Auth Code๋ฅผ ๋ฐ์์ค๊ธฐ ์ํ API๋ ๋ค์ ๋งํฌ(https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code)์ ๋์์์ต๋๋ค.
์๋์ ์ ํฌ๊ฐ ์ค์ ํ ์ ๋ณด๋ฅผ ๊ธฐ์ค์ผ๋ก,
REST API ํค(client_id) : mallang-kakao-client-id
Client Secret : mallang-kakao-secret-key
Redirect URI : http://localhost:3000/oauth/redirected/kakao
๋์ ํญ๋ชฉ : ๋๋ค์(profile_nickname), ํ๋กํ ์ฌ์ง(profile_image)
ํ์๊ฐ๋ง ์ฌ์ฉํ๋ค๊ณ ๊ฐ์ ํ์ ๋, ๋ค์๊ณผ ๊ฐ์ URL๋ก ์์ฒญ์ ๋ณด๋ด์ผ ํฉ๋๋ค.
https://kauth.kakao.com/oauth/authorize
?response_type=code
&client_id=mallang-kakao-client-id
&redirect_uri=http://localhost:3000/oauth/redirected/kakao
&scope=profile_nickname,profile_image
์ด์ ๋ง๊ฒ ๊ตฌํํ ๊ตฌํ์ฒด๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
โจ KakaoAuthCodeRequestUrlProvider
package mallang.oauth.infra.oauth.kakao.authcode;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.authcode.AuthCodeRequestUrlProvider;
import mallang.oauth.infra.oauth.kakao.KakaoOauthConfig;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
@Component
@RequiredArgsConstructor
public class KakaoAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider {
private final KakaoOauthConfig kakaoOauthConfig;
@Override
public OauthServerType supportServer() {
return OauthServerType.KAKAO;
}
@Override
public String provide() {
return UriComponentsBuilder
.fromUriString("https://kauth.kakao.com/oauth/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", kakaoOauthConfig.clientId())
.queryParam("redirect_uri", kakaoOauthConfig.redirectUri())
.queryParam("scope", String.join(",", kakaoOauthConfig.scope()))
.toUriString();
}
}
์ด๋ KakaoOauthConfig๋ ์ ์๊ฐ ์ค์ ํ ์ ๋ณด๋ฅผ ๋ด๊ณ ์๋ ํด๋์ค๋ก, ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
โจ KakaoOauthConfig
package mallang.oauth.infra.oauth.kakao;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "oauth.kakao")
public record KakaoOauthConfig(
String redirectUri,
String clientId,
String clientSecret,
String[] scope
) {
}
application.yml์ oauth.kakao๋ก ์ค์ ๋ ์ ๋ณด๋ค์ ํตํด ์์ฑ๋ฉ๋๋ค.
โจ application.yml ์์
์ด์ application.yml ํ์ผ์ ์๋์ ๊ฐ์ด ์์ ํฉ๋๋ค.
logging:
level:
org.hibernate.orm.jdbc.bind: TRACE
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
highlight_sql: true
hibernate:
ddl-auto: create
# ์ถ๊ฐ
oauth:
kakao:
client_id: mallang-kakao-client-id # REST API ํค
redirect_uri: http://localhost:3000/oauth/redirected/kakao
client_secret: mallang-kakao-secret-key # Client Secret ํค
scope: profile_nickname, profile_image
๊ทธ๋ฆฌ๊ณ @ConfigurationProperties๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์๋ @ConfigurationPropertiesScan์ด ํ์ํฉ๋๋ค.
โจ OauthApplication ์์
package mallang.oauth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class OauthApplication {
public static void main(String[] args) {
SpringApplication.run(OauthApplication.class, args);
}
}
์ด์ ์ด๋ฅผ ์ฌ์ฉํด์ผ๊ฒ ์ฃ ?
โจ AuthCodeRequestUrlProviderComposite
package mallang.oauth.domain.authcode;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import mallang.oauth.domain.OauthServerType;
import org.springframework.stereotype.Component;
@Component
public class AuthCodeRequestUrlProviderComposite {
private final Map<OauthServerType, AuthCodeRequestUrlProvider> mapping;
public AuthCodeRequestUrlProviderComposite(Set<AuthCodeRequestUrlProvider> providers) {
mapping = providers.stream()
.collect(toMap(
AuthCodeRequestUrlProvider::supportServer,
identity()
));
}
public String provide(OauthServerType oauthServerType) {
return getProvider(oauthServerType).provide();
}
private AuthCodeRequestUrlProvider getProvider(OauthServerType oauthServerType) {
return Optional.ofNullable(mapping.get(oauthServerType))
.orElseThrow(() -> new RuntimeException("์ง์ํ์ง ์๋ ์์
๋ก๊ทธ์ธ ํ์
์
๋๋ค."));
}
}
Composite ํจํด์ ์กฐ๊ธ ๋ณํํ์ฌ OauthServerType์ ์ข ๋ฅ์ ๋ฐ๋ผ, ์ด์ ํด๋นํ๋ AuthCodeRequestUrlProvier๋ฅผ ์ฌ์ฉํ์ฌ URL์ ์์ฑํ ์ ์๋๋ก ์ ํด๋์ค๋ฅผ ์์ฑํ์์ต๋๋ค.
์ด๋ฅผ ์ํด supportServer()๋ผ๋ ๋ฉ์๋๋ฅผ ๊ตฌํํ์ต๋๋ค.
์ด๋ฅผ ํตํด Naver ๋ก๊ทธ์ธ ๋ฑ์ ์ถ๊ฐํ ๋, ๊ธฐ์กด ์ฝ๋์ ๋ณ๊ฒฝ ์์ด ์๋ก ์ถ๊ฐ๋ ํ์ ์ ์ฌ์ฉํ ์ ์๊ฒ ๋ฉ๋๋ค.
์ด์ ์ด๋ฅผ ์ฌ์ฉํ์ฌ URL์ ์ค์ ๋ก ๋ง๋ค์ด๋ด๋ Service ์ฝ๋๋ฅผ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
โจ OauthService
package mallang.oauth.application;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.authcode.AuthCodeRequestUrlProviderComposite;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OauthService {
private final AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite;
public String getAuthCodeRequestUrl(OauthServerType oauthServerType) {
return authCodeRequestUrlProviderComposite.provide(oauthServerType);
}
}
OauthServerType์ ๋ฐ์์ ํด๋น ์ธ์ฆ ์๋ฒ์์ Auth Code๋ฅผ ๋ฐ์์ค๊ธฐ ์ํ URL ์ฃผ์๋ฅผ ์์ฑํด์ค๋๋ค.
์ด๋ฅผ ์ฌ์ฉํ๋ Controller ์ฝ๋๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
โจ OauthController
package mallang.oauth.presentation;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import mallang.oauth.application.OauthService;
import mallang.oauth.domain.OauthServerType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RequestMapping("/oauth")
@RestController
public class OauthController {
private final OauthService oauthService;
@SneakyThrows
@GetMapping("/{oauthServerType}")
ResponseEntity<Void> redirectAuthCodeRequestUrl(
@PathVariable OauthServerType oauthServerType,
HttpServletResponse response
) {
String redirectUrl = oauthService.getAuthCodeRequestUrl(oauthServerType);
response.sendRedirect(redirectUrl);
return ResponseEntity.ok().build();
}
}
@PathVariable์ ํตํด /oauth/kakao ๋ฑ์ ์์ฒญ์์ kakao๋ฅผ OauthServerType๋ก ๋ณํํ์ฌ ๋ฐ์์ต๋๋ค.
์ด๋ Converter๋ฅผ ๋ฑ๋กํด ์ฃผ์ด์ผ ํ๋๋ฐ, ์กฐ๊ธ ๋ค์ ์ดํด๋ณด๊ณ ์ผ๋จ ๋ก์ง์ ํ๋ฆ์ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
์ฌ์ฉ์๊ฐ ํ๋ก ํธ์๋๋ฅผ ํตํด /oauth/kakao๋ก ์ ์ํ๋ฉด ์์ ๋ฉ์๋๊ฐ ์คํ๋ฉ๋๋ค.
์ด๋ kakao๋ OauthServerType.KAKAO๋ก ๋ณํ๋ ๊ฒ์ ๋๋ค.
์ด์ ์์์ ๊ตฌํํ Service๋ฅผ ํตํด KAKAO์์ Auth Code๋ฅผ ๋ฐ์์ค๊ธฐ ์ํ URL์ ์์ฑํ๊ณ ,
์ฌ๊ธฐ์ ์์ฑ๋ URL๋ก ์ฌ์ฉ์๋ฅผ Redirect ์ํต๋๋ค.
์๋ Converter ๊ตฌํ์ ์ ๊น ์ดํด๋ณด๊ณ , ์ฌ๊ธฐ๊น์ง์ ๋์์ ์ดํด๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
โจ OauthServerTypeConverter
package mallang.oauth.presentation;
import mallang.oauth.domain.OauthServerType;
import org.springframework.core.convert.converter.Converter;
public class OauthServerTypeConverter implements Converter<String, OauthServerType> {
@Override
public OauthServerType convert(String source) {
return OauthServerType.fromName(source);
}
}
String์ OauthServerType์ผ๋ก ๋ณํํด์ค๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ด๋ฅผ ์ ์ฉํ๊ธฐ ์ํด์๋ ๋ค์๊ณผ ๊ฐ์ด WebConfigurer๋ฅผ ๊ตฌํํ์ฌ ์ถ๊ฐํด์ฃผ์ด์ผ ํฉ๋๋ค.
โจ WebConfig
package mallang.oauth.common.config;
import lombok.RequiredArgsConstructor;
import mallang.oauth.presentation.OauthServerTypeConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods(
HttpMethod.GET.name(),
HttpMethod.POST.name(),
HttpMethod.PUT.name(),
HttpMethod.DELETE.name(),
HttpMethod.PATCH.name()
)
.allowCredentials(true)
.exposedHeaders("*");
}
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new OauthServerTypeConverter());
}
}
CORS ์ค์ ๊น์ง ๋ฏธ๋ฆฌ ํด์ฃผ์์ต๋๋ค.
๐ ์ค๊ฐ ์ ๊ฒ - ๋์ ํ์ธํ๊ธฐ
react์ spring์ ๋ชจ๋ ์คํํฉ๋๋ค.
react๋ฅผ ์คํํ๊ธฐ ์ํด์๋ npm install, npm install react-router-dom axios ํ ๋ฒ ์คํ ์ดํ npm start๋ฅผ ํตํด ์คํ์ํฌ ์ ์์ ๊ฒ์ ๋๋ค.
(์ค๋ฅ๋๋ฉด ํธ๋ฌ๋ธ์ํ ํด์ฃผ์ธ์ฉ..)
http://localhost:3000์ผ๋ก ์ ์ํฉ๋๋ค.
์ฌ์ฉ์๋ ์์ ๊ฐ์ด ์นด์นด์คํก ๋ก๊ทธ์ธ ๋ฒํผ์ ๋ณด๊ณ , ํด๋ฆญํ ๊ฒ์ ๋๋ค.
(์ด๋ฅผ ํด๋ฆญํ๋ฉด, ๋ฐฑ์๋์์ Kakao ์ธ์ฆ ์๋ฒ์์ Auth code๋ฅผ ๊ฐ์ ธ์ค๊ธฐ ์ํ URL๋ก ๋ฆฌ๋ค์ด๋ ํธ ์์ผ์ค๋๋ค.)
ํด๋ฆญํ๋ฉด ์์ ๊ฐ์ ํ๋ฉด์ด ๋์ต๋๋ค.
์์ด๋์ ๋น๋ฐ๋ฒํธ๋ฅผ ์ ๋ ฅํ๊ณ ์งํํฉ๋๋ค.
์ด๋ ํ์ฌ ์ฌ์ดํธ์ URL์ ์ดํด๋ณด๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
https://kauth.kakao.com/oauth/authorize?scope=profile_nickname%2Cprofile_image&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fredirected%2Fkakao&through_account=true&client_id=๋น๋ฐ&additional_auth_login=true
์ด๋ฅผ URL Decode๋ฅผ ํตํด ๋์ฝ๋ฉํ๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
https://kauth.kakao.com/oauth/authorize
?scope=profile_nickname,profile_image
&response_type=code
&redirect_uri=http://localhost:3000/oauth/redirected/kakao
&through_account=true <-- ์๋ ๋ก๊ทธ์ธ ํ๊ณ ๋ค์ด์ค๋ฉด ์๋ ์ถ๊ฐ๋ฉ๋๋ค. ๋ฌด์ํ์ธ์
&client_id=REST API ID
&additional_auth_login=true <-- ์๋ ๋ก๊ทธ์ธ ํ๊ณ ๋ค์ด์ค๋ฉด ์๋ ์ถ๊ฐ๋ฉ๋๋ค. ๋ฌด์ํ์ธ์
๋ฐฑ์๋์์ ์ค์ ํด์ค URL์ด ์ ์๋ํ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
๋์ํ๊ณ ๊ณ์ํ๊ธฐ๋ฅผ ํด๋ฆญํด๋ณด๊ฒ ์ต๋๋ค.
Auth Code๊น์ง ์ ๋ฐ์์จ ๊ฒ์ ์ ์ ์์ต๋๋ค.
์ด์ ๊ทธ ๋ค์ ๋จ๊ณ์ธ Auth Code๋ฅผ ํตํด ์นด์นด์ค ์ธ์ฆ ์๋ฒ๋ก๋ถํฐ AccessToken์ ๋ฐ์์ค๋ ์ฝ๋๋ฅผ ์์ฑํด ๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ง 2. Auth Code๋ฅผ ํตํด ์นด์นด์ค ์ธ์ฆ ์๋ฒ๋ก๋ถํฐ AccessToken์ ๋ฐ์์จ๋ค
์ฐ์ ์นด์นด์ค ์ธ์ฆ ์๋ฒ๊ฐ ์ ๊ณตํ๋ AccessToken์ ๋ฐ์์ค๊ธฐ ์ํ API๋ ๋ค์ ๋งํฌ(https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token)์ ๋์์์ต๋๋ค.
์ด์ ๋ง๊ฒ ์นด์นด์ค ์ธ์ฆ ์๋ฒ์ ์์ฒญ์ ๋ณด๋ด๊ณ ์๋ต์ ๋ฐ์์ค๋ ๊ณผ์ ์ ๊ตฌํํ๋๋ก ํ๊ฒ ์ต๋๋ค.
์ธ๋ถ API๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์๋ ๊ฐ์ฅ ํํ ๋ฐฉ๋ฒ์ผ๋ก๋ RestTemplate์ด๋ WebClient๋ฅผ ์ฌ์ฉํ ์ ์์ผ๋ฉฐ, ๊ฐ์ํ๋ฅผ ์ํด์๋ FeignClient ๋ฑ์ ์ฌ์ฉํ๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ ๋๋ค.
๊ทธ๋ฌ๋ ์ ๋ ์ด๋ฒ์๋ ์คํ๋ง 6์ ์๋ก ์ถ๊ฐ๋ HTTP Interface Client๋ฅผ ์ฌ์ฉํ๋๋ก ํ๊ฒ ์ต๋๋ค.
์ฐ์ ์ ์์ฒญ์ ์๋ต์ ๊ฐ์ฒด๋ก ๋ฐ์์ค๊ธฐ ์ํด DTO๋ฅผ ์์ฑํ๋๋ก ํ๊ฒ ์ต๋๋ค.
โจ KakaoToken
package mallang.oauth.infra.oauth.kakao.dto;
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(SnakeCaseStrategy.class)
public record KakaoToken(
String tokenType,
String accessToken,
String idToken,
Integer expiresIn,
String refreshToken,
Integer refreshTokenExpiresIn,
String scope
) {
}
@JsonNaming(SnakeCaseStrategy.class)๋ฅผ ํตํด token_type๊ณผ ๊ฐ์ ์๋ต์ tokenType์ผ๋ก ๋ฐ์์ฌ ์ ์์ต๋๋ค.
โจ KakaoApiClient
package mallang.oauth.infra.oauth.kakao.client;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;
import mallang.oauth.infra.oauth.kakao.dto.KakaoToken;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.PostExchange;
public interface KakaoApiClient {
@PostExchange(url = "https://kauth.kakao.com/oauth/token", contentType = APPLICATION_FORM_URLENCODED_VALUE)
KakaoToken fetchToken(@RequestParam MultiValueMap<String, String> params);
}
์๋ Http Interface Client๋ฅผ ์ฌ์ฉํ ์ฝ๋์ ๋๋ค.
์ฐ์ Url์๋ AccessToken์ ๋ฐ์์ค๊ธฐ ์ํ URL์ ๋ช ์ํ์๊ณ , ์ด๋ฅผ ์ํ ContentType๊ณผ ์์ฒญ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์์ผ ํ๋ฏ๋ก MultiValueMap์ ํ๋ผ๋ฏธํฐ๋ก ์ค์ ํ์ต๋๋ค.
์๋ต ๊ฐ์ KakaoToken์ ํตํด ๊ฐ์ฒด๋ก ๋ฐ๋ก ๋ฐ์์ฌ ์ ์๋๋ก ์์ฑํ์์ต๋๋ค.
์์ฒ๋ผ ์ธํฐํ์ด์ค๋ฅผ ํตํด ๋ฐ๋ก ์ฌ์ฉํ ์ ์์ง๋ง, ํ๊ฐ์ง ์ถ๊ฐ ์์ ์ด ํ์ํฉ๋๋ค.
โจ HttpInterfaceConfig
package mallang.oauth.infra.oauth.config;
import mallang.oauth.infra.oauth.kakao.client.KakaoApiClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class HttpInterfaceConfig {
@Bean
public KakaoApiClient kakaoApiClient() {
return createHttpInterface(KakaoApiClient.class);
}
private <T> T createHttpInterface(Class<T> clazz) {
WebClient webClient = WebClient.create();
HttpServiceProxyFactory build = HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(webClient)).build();
return build.createClient(clazz);
}
}
์์ ๊ฐ์ด Http Interface Client ๊ตฌํ์ฒด๋ฅผ ๋น์ผ๋ก ๋ฑ๋กํด์ฃผ๋ ๊ณผ์ ์ด ํ์ํฉ๋๋ค.
์์ง ๊ตฌํํ์ง ์์ ํด๋์ค๋ค์ด ๋ง๊ธฐ์ ์ฌ๊ธฐ์ ๋ฐ๋ก ๋ค์์ผ๋ก ๋์ด๊ฐ๊ฒ ์ง๋ง, ๋ค์๊ณผ ๊ฐ์ด ์ฌ์ฉํ ๊ฒ์์ ์ฐธ๊ณ ๋ฅผ ์ํด ๋จ๊ฒจ๋๊ฒ ์ต๋๋ค.
@Component
@RequiredArgsConstructor
public class KakaoMemberClient implements OauthMemberClient {
private final KakaoApiClient kakaoApiClient;
private final KakaoOauthConfig kakaoOauthConfig;
@Override
public OauthMember fetch(String authCode) {
KakaoToken tokenInfo = kakaoApiClient.fetchToken(tokenRequestParams(authCode));
// ์๋ต
}
private MultiValueMap<String, String> tokenRequestParams(String authCode) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", kakaoOauthConfig.clientId());
params.add("redirect_uri", kakaoOauthConfig.redirectUri());
params.add("code", authCode);
params.add("client_secret", kakaoOauthConfig.clientSecret());
return params;
}
}
๐ง 3. AccessToken์ ํตํด ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ฐ์์จ๋ค
์ด์ ์์์ ๋ฐ๊ธ๋ฐ์ AccessToken์ ํตํด ์ฌ์ฉ์์ ์ ๋ณด๋ฅผ ๋ฐ์์์ผ ํฉ๋๋ค.
์ด์ ๋ํ API๋ ๋ค์ ๋งํฌ(https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info)์์ ํ์ธํ ์ ์์ต๋๋ค.
์ ํ์์ ๋ง๊ฒ AccessToken์ ๋ฐ๊ธํ ๋ ์ฒ๋ผ HTTP Interface Client๋ฅผ ์ฌ์ฉํ์ฌ ๊ตฌํํ๋๋ก ํ๊ฒ ์ต๋๋ค.
์ฐ์ ์๋ต์ ๋งคํํ ํด๋์ค๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
โจ KakaoMemberResponse
package mallang.oauth.infra.oauth.kakao.dto;
import static mallang.oauth.domain.OauthServerType.KAKAO;
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import java.time.LocalDateTime;
import mallang.oauth.domain.OauthId;
import mallang.oauth.domain.OauthMember;
@JsonNaming(SnakeCaseStrategy.class)
public record KakaoMemberResponse(
Long id,
boolean hasSignedUp,
LocalDateTime connectedAt,
KakaoAccount kakaoAccount
) {
public OauthMember toDomain() {
return OauthMember.builder()
.oauthId(new OauthId(String.valueOf(id), KAKAO))
.nickname(kakaoAccount.profile.nickname)
.profileImageUrl(kakaoAccount.profile.profileImageUrl)
.build();
}
@JsonNaming(SnakeCaseStrategy.class)
public record KakaoAccount(
boolean profileNeedsAgreement,
boolean profileNicknameNeedsAgreement,
boolean profileImageNeedsAgreement,
Profile profile,
boolean nameNeedsAgreement,
String name,
boolean emailNeedsAgreement,
boolean isEmailValid,
boolean isEmailVerified,
String email,
boolean ageRangeNeedsAgreement,
String ageRange,
boolean birthyearNeedsAgreement,
String birthyear,
boolean birthdayNeedsAgreement,
String birthday,
String birthdayType,
boolean genderNeedsAgreement,
String gender,
boolean phoneNumberNeedsAgreement,
String phoneNumber,
boolean ciNeedsAgreement,
String ci,
LocalDateTime ciAuthenticatedAt
) {
}
@JsonNaming(SnakeCaseStrategy.class)
public record Profile(
String nickname,
String thumbnailImageUrl,
String profileImageUrl,
boolean isDefaultImage
) {
}
}
ํ์์๋ ํ๋๋ ๋ง์ง๋ง, ์ธ์ ์ด๋ป๊ฒ ๋ฐ๋์ง ๋ชจ๋ฅด๊ธฐ์ ๊ทธ๋ฅ ํ๋ฒ์ ๋ค ๋ฑ๋กํด ์ฃผ์์ต๋๋ค.
์ด๋ toDomain() ๋ฉ์๋๋ฅผ ํตํด์ ๋ฐ์์จ ์ ๋ณด๋ฅผ ํ ๋๋ก OauthMember๋ฅผ ๋ง๋ค์ด ์ฃผ๋ ๊ฒ์ ์ ์ ์์ต๋๋ค.
OauthMember๋ Oauth๋ฅผ ํตํด ๊ฐ์ ํ ํ์์ ๋ํ๋ด๋ ๊ฐ์ฒด์ด๋ฉฐ, ์ด๋ฅผ ๊ตฌํํ๋๋ก ํ๊ฒ ์ต๋๋ค.
โจ OauthId
package mallang.oauth.domain;
import static jakarta.persistence.EnumType.STRING;
import static lombok.AccessLevel.PROTECTED;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Enumerated;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
@Embeddable
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
public class OauthId {
@Column(nullable = false, name = "oauth_server_id")
private String oauthServerId;
@Enumerated(STRING)
@Column(nullable = false, name = "oauth_server")
private OauthServerType oauthServerType;
public String oauthServerId() {
return oauthServerId;
}
public OauthServerType oauthServer() {
return oauthServerType;
}
}
ํน์ ์ธ์ฆ ์๋ฒ์ ์๋ณ์ ๊ฐ์ ์๋ฏธํ๋ oauthServerId์, ์ด๋ฅผ ์ ๊ณตํ๋ ์๋น์ค ํ์ ์ ๋ฌถ์ด OauthId๋ผ๋ ํด๋์ค๋ฅผ ๋ง๋ค์ด ์ฃผ์์ต๋๋ค.
ํ๋์ ์๋น์ค ๋ด์์ ์๋ณ์๋ ์ค๋ณต๋์ง ์๊ฒ ์ง๋ง ์๋ก ๋ค๋ฅธ ์๋น์ค๊ฐ์๋ ์๋ณ์๊ฐ ์ค๋ณต๋ ์๋ ์๊ฒ ๋ค ์ถ์๊ธฐ ๋๋ฌธ์ ์์ ๊ฐ์ด ๊ตฌํํ์ฌ ํน์ ๋ชจ๋ฅผ ์๋ณ์์ ์ค๋ณต์ ์๋ฐฉํ๋ ค ํ์์ต๋๋ค.
โจ OauthMember
package mallang.oauth.domain;
import static lombok.AccessLevel.PROTECTED;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
@Table(name = "oauth_member",
uniqueConstraints = {
@UniqueConstraint(
name = "oauth_id_unique",
columnNames = {
"oauth_server_id",
"oauth_server"
}
),
}
)
public class OauthMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private OauthId oauthId;
private String nickname;
private String profileImageUrl;
public Long id() {
return id;
}
public OauthId oauthId() {
return oauthId;
}
public String nickname() {
return nickname;
}
public String profileImageUrl() {
return profileImageUrl;
}
}
์๋ ์ฆ๊ฐ๋๋ id ํ๋์, uniqueConstraints๋ฅผ ํตํด OauthId์ ์ ์ผํจ์ ๋ณด์ฅํ๋๋ก ํด์ฃผ์์ต๋๋ค.
๋ํ ์ฌ์ฉ์์ ๋ณ๋ช ๊ณผ ํ๋กํ ์ฌ์ง URL์ ๋ํ ์ ๋ณด๋ฅผ ์ ๊ณต๋ฐ๊ธฐ๋ก ํ์ผ๋ฏ๋ก, ์ด๋ฅผ ์ ์ฅํ๊ธฐ ์ํด ํ๋๋ฅผ ์ถ๊ฐํด ์ฃผ์์ต๋๋ค.
โจ OauthMemberRepository
package mallang.oauth.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OauthMemberRepository extends JpaRepository<OauthMember, Long> {
Optional<OauthMember> findByOauthId(OauthId oauthId);
}
๊ฐ๋จํ๋ฏ๋ก ๋์ด๊ฐ๊ฒ ์ต๋๋ค.
์ด์ ๊ตฌํํด์ผ ํ ๊ฒ์ AccessToken์ ํตํด ์ฌ์ฉ์์ ์ ๋ณด๋ฅผ ๋ฐ์์ OauthMember๋ฅผ ์์ฑํ๋ ๋ก์ง์ ๋๋ค.
์๊น ์์ฑํด๋ KakaoApiClient์ ๋ค์ ๋ฉ์๋๋ฅผ ์ถ๊ฐํฉ๋๋ค.
โจ KakaoApiClient
package mallang.oauth.infra.oauth.kakao.client;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;
import mallang.oauth.infra.oauth.kakao.dto.KakaoMemberResponse;
import mallang.oauth.infra.oauth.kakao.dto.KakaoToken;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface KakaoApiClient {
@PostExchange(url = "https://kauth.kakao.com/oauth/token", contentType = APPLICATION_FORM_URLENCODED_VALUE)
KakaoToken fetchToken(@RequestParam MultiValueMap<String, String> params);
// ์ถ๊ฐ
@GetExchange("https://kapi.kakao.com/v2/user/me")
KakaoMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String bearerToken);
}
์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ API์ ์๋ง๊ฒ ๋ฉ์๋๋ฅผ ์์ฑํด ์ฃผ์์ต๋๋ค.
์ด๋ ๊ฒ ํ๋ฉด AccessToken์ ํตํด ํ์ ์ ๋ณด๋ฅผ ์กฐํํ๋ ๊ธฐ๋ฅ๋ ๊ตฌํ์ด ์๋ฃ๋์์ต๋๋ค.
์ด์ 2๋ฒ๊ณผ 3๋ฒ ๊ณผ์ ์ ํฉ์ณ์ Auth Code๋ฅผ ํตํด ์ต์ข ์ ์ผ๋ก OauthMember๋ฅผ ์์ฑํ๋ ๋ก์ง์ ์์ฑํด ๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
์ด๋ฅผ ์ํด ์ฐ์ OauthMemberClient๋ผ๋ ์ธํฐํ์ด์ค๋ฅผ ์ ์ํ์ต๋๋ค.
โจ OauthMemberClient
package mallang.oauth.domain.client;
import mallang.oauth.domain.OauthMember;
import mallang.oauth.domain.OauthServerType;
public interface OauthMemberClient {
OauthServerType supportServer();
OauthMember fetch(String code);
}
์ฌ๊ธฐ์๋ AuthCodeRequestUrlProvider์ ๋น์ทํ๊ฒ supporServer()๋ฅผ ์ ์ํด์ฃผ์๊ณ ,
fetch() ๋ฉ์๋๋ฅผ ํตํด OauthMember๋ฅผ ๋ฐํํด์ฃผ๋๋ก ํ์์ต๋๋ค.
fetch() ๋ฉ์๋๋ ์ธ์๋ก Auth Code๋ฅผ ๋ฐ์ต๋๋ค.
ํ์๊ฐ์ ํน์ ๋ก๊ทธ์ธ ํ ํ์ ์ ๋ณด๋ฅผ ๋ฐ์์ค๋ ๊ณผ์ ์ ๋์ดํ๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- Auth Code๋ฅผ ํตํด AccessToken ๋ฐ๊ธ
- AccessToken์ ํตํด ํ์ ์ ๋ณด ์กฐํ
์ด ๊ณผ์ ์ Auth Code๋ฅผ ํตํด ํ์ ์ ๋ณด๋ฅผ ์กฐํํ๋ ๊ณผ์ ์ผ๋ก ์บก์ํํ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ AuthCodeRequestUrlProviderComposite๊ณผ ๋น์ทํ๊ฒ, OauthMemberClientComposite์ ๊ตฌํํ์์ต๋๋ค.
โจ OauthMemberClientComposite
package mallang.oauth.domain.client;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import mallang.oauth.domain.OauthMember;
import mallang.oauth.domain.OauthServerType;
import org.springframework.stereotype.Component;
@Component
public class OauthMemberClientComposite {
private final Map<OauthServerType, OauthMemberClient> mapping;
public OauthMemberClientComposite(Set<OauthMemberClient> clients) {
mapping = clients.stream()
.collect(toMap(
OauthMemberClient::supportServer,
identity()
));
}
public OauthMember fetch(OauthServerType oauthServerType, String authCode) {
return getClient(oauthServerType).fetch(authCode);
}
private OauthMemberClient getClient(OauthServerType oauthServerType) {
return Optional.ofNullable(mapping.get(oauthServerType))
.orElseThrow(() -> new RuntimeException("์ง์ํ์ง ์๋ ์์
๋ก๊ทธ์ธ ํ์
์
๋๋ค."));
}
}
์ด ์ญ์๋ ์นด์นด์ค ๋ฟ๋ง ์๋๋ผ ๋ค๋ฅธ ์๋น์ค๋ค์ ๊ธฐ์กด ์ฝ๋์ ๋ณ๊ฒฝ ์์ด ์ถ๊ฐํ๊ธฐ ์ํด ์์ฑํ ํด๋์ค์ ๋๋ค.
์ด์ OauthMemberClient๋ฅผ ์นด์นด์ค ์๋น์ค์ ๋ง๊ฒ ๊ตฌํํ๋๋ก ํ๊ฒ ์ต๋๋ค.
โจ KakaoMemberClient
package mallang.oauth.infra.oauth.kakao;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthMember;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.client.OauthMemberClient;
import mallang.oauth.infra.oauth.kakao.client.KakaoApiClient;
import mallang.oauth.infra.oauth.kakao.dto.KakaoMemberResponse;
import mallang.oauth.infra.oauth.kakao.dto.KakaoToken;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@Component
@RequiredArgsConstructor
public class KakaoMemberClient implements OauthMemberClient {
private final KakaoApiClient kakaoApiClient;
private final KakaoOauthConfig kakaoOauthConfig;
@Override
public OauthServerType supportServer() {
return OauthServerType.KAKAO;
}
@Override
public OauthMember fetch(String authCode) {
KakaoToken tokenInfo = kakaoApiClient.fetchToken(tokenRequestParams(authCode)); // (1)
KakaoMemberResponse kakaoMemberResponse =
kakaoApiClient.fetchMember("Bearer " + tokenInfo.accessToken()); // (2)
return kakaoMemberResponse.toDomain(); // (3)
}
private MultiValueMap<String, String> tokenRequestParams(String authCode) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", kakaoOauthConfig.clientId());
params.add("redirect_uri", kakaoOauthConfig.redirectUri());
params.add("code", authCode);
params.add("client_secret", kakaoOauthConfig.clientSecret());
return params;
}
}
์ด๋ ต์ง๋ ์์ ๊ฒ์ด๋ผ ์๊ฐ๋์ง๋ง, fetch() ๋ฉ์๋์ ๋ํด ์ค๋ช ์ ์งํํ๋๋ก ํ๊ฒ ์ต๋๋ค.
(1) - ๋จผ์ Auth Code๋ฅผ ํตํด์ AccessToken์ ์กฐํํฉ๋๋ค.
(2) - AccessToken์ ๊ฐ์ง๊ณ ํ์ ์ ๋ณด๋ฅผ ๋ฐ์์ต๋๋ค.
(3) - ํ์ ์ ๋ณด๋ฅผ OauthMember ๊ฐ์ฒด๋ก ๋ณํํฉ๋๋ค.
์ด๋ (1)๋ฒ ๊ณผ์ ์์ ์ฌ์ฉ๋๋ tokenRequestParams()๋ ํ์ฐธ ์ ์ค๋ช ์์ ์ ๊น ์ฐธ๊ณ ์ฉ์ผ๋ก ๋ฑ์ฅํ๋๋ฐ์,
ํ ํฐ ๋ฐ๊ธฐ API์ ์์ฒญ์ ์ฌ์ฉ๋๋ ์์ฒญ ํ๋ผ๋ฏธํฐ๋ฅผ ์ค์ ํด์ค๋๋ค.
์ด์ธ์๋ ๊ทธ๋ฅ KakaoApiClient๋ฅผ ์ฌ์ฉํ๋ ์ฝ๋์ด๋ฏ๋ก ๋์ด๊ฐ๊ฒ ์ต๋๋ค.
์ด์ ํด๋น ๊ณผ์ ์ ํตํด ์์ฑํ OauthMember ๊ฐ์ฒด์ ๋ํด์ ๋ก๊ทธ์ธ์ ์งํํ๋ ์ฝ๋๋ฅผ ์์ฑํด์ผ ํฉ๋๋ค.
์ด๋ ๋ง์ฝ ํ์๊ฐ์ ๋์ด์์ง ์๋ค๋ฉด ํ์๊ฐ์ ๋ ํจ๊ป ์งํํฉ๋๋ค.
๐ง 4. ๋ฐ์์จ ์ ๋ณด๋ฅผ ํตํด ๋ก๊ทธ์ธ์ ์งํํ๋ค. ์ด๋ ํ์๊ฐ์ ๋์ด์์ง ์๋ค๋ฉด ํ์๊ฐ์ ๋ ํจ๊ป ์งํํ๋ค.
OauthService์ ์ด๋ฅผ ์ํ ๋ฉ์๋๋ฅผ ์ถ๊ฐํฉ๋๋ค.
โจ OauthService
package mallang.oauth.application;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthMember;
import mallang.oauth.domain.OauthMemberRepository;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.authcode.AuthCodeRequestUrlProviderComposite;
import mallang.oauth.domain.client.OauthMemberClientComposite;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OauthService {
private final AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite;
private final OauthMemberClientComposite oauthMemberClientComposite;
private final OauthMemberRepository oauthMemberRepository;
public String getAuthCodeRequestUrl(OauthServerType oauthServerType) {
return authCodeRequestUrlProviderComposite.provide(oauthServerType);
}
// ์ถ๊ฐ
public Long login(OauthServerType oauthServerType, String authCode) {
OauthMember oauthMember = oauthMemberClientComposite.fetch(oauthServerType, authCode);
OauthMember saved = oauthMemberRepository.findByOauthId(oauthMember.oauthId())
.orElseGet(() -> oauthMemberRepository.save(oauthMember));
return saved.id();
}
}
์ฝ๋๋ ๋๊ฒ ๊ฐ๋จํฉ๋๋ค.
๋จผ์ ๋ก๊ทธ์ธ์ ์งํํ๋ ค๋ OauthServerType์ ํด๋นํ๋ ํ์์ AuthCode๋ฅผ ํตํด ์กฐํํฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ์ ๋ํฌํจ์ด ๋ณด์ฅ๋๋ OauthId๋ฅผ ํตํด ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ํ์์ ์ฐพ์์ต๋๋ค.
๋ง์ฝ ๊ฐ์ ๋์ด์์ง ์๋ค๋ฉด ์ ์ฅ(ํ์๊ฐ์ )ํฉ๋๋ค.
์๋๋ JWT๋ฅผ ์ฌ์ฉํ๋ค๋ฉด ์ฌ๊ธฐ์ JWT๋ก AccessToken์ ์์ฑํ์ฌ ๋ฐํํ์ฌ์ผ ํ์ง๋ง, JWT๊น์ง ๋ค๋ฃจ๊ธฐ์๋ ์ด๋ฏธ ๋ถ๋์ด ๋๋ฌด ๋ง์ผ๋ฏ๋ก Id๋ฅผ ๋ฐํํ๋ ๊ฒ์ผ๋ก ์ ํผ์ ํํํ๊ณ ๋์ด๊ฐ์์ต๋๋ค :)
์ด์ ์ด๋ฅผ Controller์์ ์ฌ์ฉํด์ฃผ๋ฉด ๋ฉ๋๋ค.
โจ OauthController
package mallang.oauth.presentation;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import mallang.oauth.application.OauthService;
import mallang.oauth.domain.OauthServerType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RequestMapping("/oauth")
@RestController
public class OauthController {
private final OauthService oauthService;
@SneakyThrows
@GetMapping("/{oauthServerType}")
ResponseEntity<Void> redirectAuthCodeRequestUrl(
@PathVariable OauthServerType oauthServerType,
HttpServletResponse response
) {
String redirectUrl = oauthService.getAuthCodeRequestUrl(oauthServerType);
response.sendRedirect(redirectUrl);
return ResponseEntity.ok().build();
}
// ์ถ๊ฐ
@GetMapping("/login/{oauthServerType}")
ResponseEntity<Long> login(
@PathVariable OauthServerType oauthServerType,
@RequestParam("code") String code
) {
Long login = oauthService.login(oauthServerType, code);
return ResponseEntity.ok(login);
}
}
๋๋ฌด ๊ธธ์ด์ ธ์ ๊น๋จน์ผ์ จ์ ์๋ ์๊ฒ ์ง๋ง, ์ฌ์ฉ์๊ฐ ์นด์นด์คํก์ผ๋ก ๋ก๊ทธ์ธ + ์ ๋ณด ์ ๊ณต ๋์๋ฅผ ์งํํ๋ฉด ํ๋ก ํธ์๋๋ก Redirect๋๋ฉฐ, ํ๋ก ํธ์๋๋ ์ด๋ code๋ฅผ ๋ฐ์์ http://localhost:8080/oauth/login/kakao๋ก ๋ณด๋ด๊ธฐ๋ก ํ์์ต๋๋ค.
๊ทธ ์ฝ๋๋ KakaoRedirectPage.js์ ์์ต๋๋ค.
import React, {useEffect} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import axios from 'axios';
const KakaoRedirectPage = () => {
const location = useLocation();
const navigate = useNavigate();
const handleOAuthKakao = async (code) => {
try {
// ์นด์นด์ค๋ก๋ถํฐ ๋ฐ์์จ code๋ฅผ ์๋ฒ์ ์ ๋ฌํ์ฌ ์นด์นด์ค๋ก ํ์๊ฐ์
& ๋ก๊ทธ์ธํ๋ค
const response = await axios.get(`http://localhost:8080/oauth/login/kakao?code=${code}`);
const data = response.data; // ์๋ต ๋ฐ์ดํฐ
alert("๋ก๊ทธ์ธ ์ฑ๊ณต: " + data)
navigate("/success");
} catch (error) {
navigate("/fail");
}
};
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const code = searchParams.get('code'); // ์นด์นด์ค๋ Redirect ์ํค๋ฉด์ code๋ฅผ ์ฟผ๋ฆฌ ์คํธ๋ง์ผ๋ก ์ค๋ค.
if (code) {
alert("CODE = " + code)
handleOAuthKakao(code);
}
}, [location]);
return (
<div>
<div>Processing...</div>
</div>
);
};
export default KakaoRedirectPage;
์ด๋ ๊ฒ ๋๋ฉด ์นด์นด์คํก ํ์๊ฐ์ &๋ก๊ทธ์ธ ๊ธฐ๋ฅ ๊ตฌํ์ด ์๋ฃ๋ฉ๋๋ค.
ํ๋ฒ ํ ์คํธ ํด๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค.
http://localhost:3000์ผ๋ก ์ด๋ํฉ๋๋ค.
๋ฒํผ์ ๋๋ฅด๊ณ ์งํํฉ๋๋ค.
๊ณ์ ์งํํฉ๋๋ค.
Auth Code๋ฅผ ์ ๋ฐ์์ค๋ ๊ฒ ๊น์ง๋ ์ด์ ์ ํ์ธํ์ต๋๋ค.
ํ์ธ์ ๋๋ฅด๊ณ ์๋ฒ ๋ก๊ทธ๋ฅผ ๋ณด๋ฉด ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
ํ์๊ฐ์ ์ด ์์ฃผ ์ ๋๋ ๊ฒ์ ํ์ธํ์ต๋๋ค.
์ด๋ ๊ฒ ํด์ ์นด์นด์คํก ๋ก๊ทธ์ธ ๊ตฌํ์ด ์๋ฃ๋์์ต๋๋ค.
๊ทธ๋ฐ๋ฐ...!
์นด์นด์ค ๋ก๊ทธ์ธ๋ง ํ ๊ฑฐ์์ผ๋ฉด ์ด๋ ๊ฒ๊น์ง ์ฝ๋๋ฅผ ์ง์ง๋ ์์์๊ฒ๋๋ค.
ํฌ์คํ ์ ์ ๋ชฉ๊ณผ ๊ฐ์ด ํ์ฅ์ฑ ์๋๋ก ๊ตฌํํ๋ ๊ฒ์ด ์ด๋ฒ ํฌ์คํ ์ ๋ชฉ์ ์ด์๋๋ฐ, ์ด๋ ๊ฒ ๋๋ด๋ฉด ์๋๊ฒ ์ฃ ??
์ข ๊ธธ์ด์ง์ง๋ง ๋ค์ด๋ฒ๋ง ํ๋ฑ ์ถ๊ฐํ๊ณ ๋๋ด๋๋ก ํ๊ฒ ์ต๋๋ค.
๐ง ์๋ก์ด ์๋น์ค๋ฅผ ๋ฑ๋กํ๋ ๋ฐฉ๋ฒ
์ง๊ธ๊น์ง์ ์ฝ๋๋ฅผ ํตํด Oauth ๋ก๊ทธ์ธ์ ๊ธฐ๋ณธ ๊ตฌ์กฐ๊ฐ ๊ฐ์ถ์ด์ก์ต๋๋ค.
์ด์ ์๋ก์ด ์๋น์ค๋ฅผ ๋ฑ๋กํ๊ธฐ ์ํ ๊ณผ์ ์ ์ดํด๋ณด๊ฒ ์ต๋๋ค.
๋ค์ด๋ฒ๋ฅผ ์์๋ก ๋ค์ด๋ณด๊ฒ ์ต๋๋ค.
๐ณ 1. ๊ฐ๋ฐ์ ์ ํ๋ฆฌ์ผ์ด์ ์ถ๊ฐ
๋ค์ด๋ฒ๋ผ๋ฉด ๋ค์ด๋ฒ ๊ฐ๋ฐ์ ์ฌ์ดํธ(https://developers.naver.com/apps/#/list)๋ก ์ด๋ํ์ฌ, ์ ํ๋ฆฌ์ผ์ด์ ์ ์์ฑํ๊ณ ์ค์ ํฉ๋๋ค.
์ด๋ redirect_uri๋ฅผ ๋ฑ๋กํ ๋์๋ http://localhost:3030/oauth/redirected/{์๋น์ค ๋ณ์นญ?(kakao, naver, google ๋ฑ ์์๋ก ์ ์)}์ ํ์์ผ๋ก ๋ฑ๋กํด์ค๋๋ค.
์ฐธ๊ณ ๋ก http://localhost:3000 ๊ฐ์ ๊ฒฝ์ฐ, ๋น์ฐํ ๋ฐฐํฌ์์๋ ๋ฐฐํฌ๋ ์๋ฒ์ ๋๋ฉ์ธ์ ์ ๋ ฅํด์ฃผ์ด์ผ ํฉ๋๋ค.
๊ฐ์ด ํด๋ณด๊ฒ ์ต๋๋ค.
๋ค์ด๋ฒ ๊ฐ๋ฐ์ ์ฌ์ดํธ(https://developers.naver.com/apps/#/list)๋ก ์ด๋ํฉ๋๋ค.
Application ๋ฑ๋ก์ ํด๋ฆญํฉ๋๋ค.
์์ ๊ฐ์ด ์ค์ ํด์ค๋๋ค.
์ด๋ CallbackUrl์ ์นด์นด์ค์ ๋น์ทํ์ง๋ง, ๋ง์ง๋ง์ด kakao๊ฐ ์๋ naver๋ก ๋ณ๊ฒฝ๋์๋ค๋ ์ ๋ง ๋ค๋ฆ ๋๋ค.
์ ๋๊ฐ๋ฅผ ์ด๋๊ฐ์ ์ ์ฅํด๋ก๋๋ค.
๐ณ 2. OauthServerType์ ์๋น์ค ์ถ๊ฐ
public enum OauthServerType {
KAKAO,
NAVER,
;
public static OauthServerType fromName(String type) {
return OauthServerType.valueOf(type.toUpperCase(ENGLISH));
}
}
๊ทธ๋ฅ ์ด๋ฆ์ ๋ง๊ฒ ์ถ๊ฐํด์ฃผ์๋ฉด ๋ฉ๋๋ค.
๐ณ 3. application.yml์ ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ณด ๋ฑ๋ก
oauth:
kakao:
client_id: ${KAKAO_CLIENT_ID}
redirect_uri: ${KAKAO_REDIRECT_URI}
client_secret: ${KAKAO_CLIENT_SECRET}
scope: ${KAKAO_SCOPE}
naver:
client_id: ${NAVER_CLIENT_ID}
redirect_uri: ${NAVER_REDIRECT_URI}
client_secret: ${NAVER_CLIENT_SECRET}
scope: ${NAVER_SCOPE}
1๋ฒ์์ ์ค์ ํ client_id, secret, redirect_uri, , scope(์์ผ๋ฉด ๊ณต๋ฐฑ)๋ฅผ ๋ฑ๋กํด์ค๋๋ค.
ํด๋น ์ค์ ์ ๋ณด ํด๋์ค๋ ๋ค์๊ณผ ๊ฐ์ด ๊ตฌํํฉ๋๋ค.
package mallang.oauth.infra.oauth.naver;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "oauth.naver")
public record NaverOauthConfig(
String redirectUri,
String clientId,
String clientSecret,
String[] scope,
String state
) {
}
๐ณ 4. AuthCodeRequestUrlProvider ๊ตฌํ
๐ Auth Code ๋ฐ๊ธ API ๋ช ์ธ
Auth Code์ AccessToken ๊ด๋ จ API ๋ช ์ธ๋์ ํด๋น ๋งํฌ์ ์์ต๋๋ค.
์ด์ ๋ง๊ฒ ๊ตฌํํฉ๋๋ค.
package mallang.oauth.infra.oauth.naver.authcode;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.authcode.AuthCodeRequestUrlProvider;
import mallang.oauth.infra.oauth.naver.NaverOauthConfig;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
@Component
@RequiredArgsConstructor
public class NaverAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider {
private final NaverOauthConfig naverOauthConfig;
@Override
public OauthServerType supportServer() {
return OauthServerType.NAVER;
}
@Override
public String provide() {
return UriComponentsBuilder
.fromUriString("https://nid.naver.com/oauth2.0/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", naverOauthConfig.clientId())
.queryParam("redirect_uri", naverOauthConfig.redirectUri())
.queryParam("state", "samplestate") // ์ด๊ฑด ๋์ค์ ๋ฐ๋ก ์ฐพ์๋ณด๊ณ ์ค์ ํด์ ์ฐ์ธ์ฉ!
.build()
.toUriString();
}
}
๐ณ 5. AccessToken ๋ฐ๊ธ & ํ์ ์ ๋ณด ์กฐํ API Cleint ๊ตฌํ
๐ AcessToken ๋ฐ๊ธ API ๋ช ์ธ
AccessToken ๊ด๋ จ๋ ํด๋น ๋งํฌ์ ์์ต๋๋ค.
๐ ํ์ ์ ๋ณด ์กฐํ API ๋ช ์ธ
ํ์ ์ ๋ณด ์กฐํ๋ ํด๋น ๋งํฌ์ ์กด์ฌํฉ๋๋ค.
์ด์ ๋ง๊ฒ ๊ตฌํํฉ๋๋ค.
package mallang.oauth.infra.oauth.naver.client;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import mallang.oauth.infra.oauth.naver.dto.NaverMemberResponse;
import mallang.oauth.infra.oauth.naver.dto.NaverToken;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface NaverApiClient {
@PostExchange(url = "https://nid.naver.com/oauth2.0/token")
NaverToken fetchToken(@RequestParam MultiValueMap<String, String> params);
@GetExchange("https://openapi.naver.com/v1/nid/me")
NaverMemberResponse fetchMember(@RequestHeader(name = AUTHORIZATION) String bearerToken);
}
package mallang.oauth.infra.oauth.naver.dto;
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(value = SnakeCaseStrategy.class)
public record NaverToken(
String accessToken,
String refreshToken,
String tokenType,
Integer expiresIn,
String error,
String errorDescription
) {
}
package mallang.oauth.infra.oauth.naver.dto;
import static mallang.oauth.domain.OauthServerType.NAVER;
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import mallang.oauth.domain.OauthId;
import mallang.oauth.domain.OauthMember;
@JsonNaming(value = SnakeCaseStrategy.class)
public record NaverMemberResponse(
String resultcode,
String message,
Response response
) {
public OauthMember toDomain() {
return OauthMember.builder()
.oauthId(new OauthId(String.valueOf(response.id), NAVER))
.nickname(response.nickname)
.profileImageUrl(response.profileImage)
.build();
}
@JsonNaming(value = SnakeCaseStrategy.class)
public record Response(
String id,
String nickname,
String name,
String email,
String gender,
String age,
String birthday,
String profileImage,
String birthyear,
String mobile
) {
}
}
๊ทธ๋ฆฌ๊ณ HttpInterfaceConfig์ ๋น์ผ๋ก ๋ฑ๋กํฉ๋๋ค.
package mallang.oauth.infra.oauth.config;
import mallang.oauth.infra.oauth.kakao.client.KakaoApiClient;
import mallang.oauth.infra.oauth.naver.client.NaverApiClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class HttpInterfaceConfig {
@Bean
public KakaoApiClient kakaoApiClient() {
return createHttpInterface(KakaoApiClient.class);
}
@Bean
public NaverApiClient naverApiClient() {
return createHttpInterface(NaverApiClient.class);
}
private <T> T createHttpInterface(Class<T> clazz) {
WebClient webClient = WebClient.create();
HttpServiceProxyFactory build = HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(webClient)).build();
return build.createClient(clazz);
}
}
๐ณ 6. OauthMemberClient ๊ตฌํ
package mallang.oauth.infra.oauth.naver;
import lombok.RequiredArgsConstructor;
import mallang.oauth.domain.OauthMember;
import mallang.oauth.domain.OauthServerType;
import mallang.oauth.domain.client.OauthMemberClient;
import mallang.oauth.infra.oauth.naver.client.NaverApiClient;
import mallang.oauth.infra.oauth.naver.dto.NaverMemberResponse;
import mallang.oauth.infra.oauth.naver.dto.NaverToken;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@Component
@RequiredArgsConstructor
public class NaverMemberClient implements OauthMemberClient {
private final NaverApiClient naverApiClient;
private final NaverOauthConfig naverOauthConfig;
@Override
public OauthServerType supportServer() {
return OauthServerType.NAVER;
}
@Override
public OauthMember fetch(String authCode) {
NaverToken tokenInfo = naverApiClient.fetchToken(tokenRequestParams(authCode));
NaverMemberResponse naverMemberResponse = naverApiClient.fetchMember("Bearer " + tokenInfo.accessToken());
return naverMemberResponse.toDomain();
}
private MultiValueMap<String, String> tokenRequestParams(String authCode) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", naverOauthConfig.clientId());
params.add("client_secret", naverOauthConfig.clientSecret());
params.add("code", authCode);
params.add("state", naverOauthConfig.state());
return params;
}
}
์ด๋ฌ๋ฉด ๋ค์ด๋ฒ ๋ก๊ทธ์ธ๋ ๊ตฌํ์ด ๋๋ฉ๋๋ค.
๐ง ๋ค์ด๋ฒ ๋ก๊ทธ์ธ ํ ์คํธ
ํ์ธํ๊ณ ์ถ์ผ๋ฉด ํ๋ก ํธ์๋์ ๋ค์ ์ฝ๋๋ฅผ ์ถ๊ฐํฉ๋๋ค.
import React, {useEffect} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import axios from 'axios';
const NaverRedirectPage = () => {
const location = useLocation();
const navigate = useNavigate();
const handleOAuthKakao = async (code) => {
try {
// ๋ค์ด๋ฒ๋ก๋ถํฐ ๋ฐ์์จ code๋ฅผ ์๋ฒ์ ์ ๋ฌํ์ฌ ์นด์นด์ค๋ก ํ์๊ฐ์
& ๋ก๊ทธ์ธํ๋ค
const response = await axios.get(`http://localhost:8080/oauth/login/naver?code=${code}`);
const data = response.data; // ์๋ต ๋ฐ์ดํฐ
alert("๋ก๊ทธ์ธ ์ฑ๊ณต: " + data)
navigate("/success");
} catch (error) {
navigate("/fail");
}
};
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const code = searchParams.get('code'); // ๋ค์ด๋ฒ๋ Redirect ์ํค๋ฉด์ code๋ฅผ ์ฟผ๋ฆฌ ์คํธ๋ง์ผ๋ก ์ค๋ค.
if (code) {
alert("CODE = " + code)
handleOAuthKakao(code);
}
}, [location]);
return (
<div>
<div>Processing...</div>
</div>
);
};
export default NaverRedirectPage;
kakao์ ๋น๊ตํด์ axios.get์ url์ ๋ง์ง๋ง ๋ถ๋ถ์ด kakao ์์ naver๋ก๋ง ๋ฐ๋์์ต๋๋ค.
(๋ ์ด์๊ฒ ํ ์ ์์์ ๊ฑฐ ๊ฐ์๋ฐ, ์ ๊ฐ ํ๋ก ํธ ํ ์ค์ ๋ชฐ๋ผ์ ๊ทธ๋ฅ ์ด๋ ๊ฒ ์ค๋ณต ๋ง๋ค๊ฒ์..ใ )
๊ทธ๋ฆฌ๊ณ ๋ผ์ฐํ ์ ์ถ๊ฐํฉ๋๋ค.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoginPage from "./LoginPage";
import KakaoRedirectPage from "./KakaoRedirectPage";
import NaverRedirectPage from "./NaverRedirectPage";
const App = () => {
return (
<div className='App'>
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginPage />}></Route>
<Route path="/oauth/redirected/kakao" element={<KakaoRedirectPage />}></Route>
<Route path="/oauth/redirected/naver" element={<NaverRedirectPage />}></Route>
</Routes>
</BrowserRouter>
</div>
);
};
export default App;
๋ฉ์ธ ๋ก๊ทธ์ธ ํ์ด์ง๋ ๋ฒํผ์ ์ถ๊ฐํฉ๋๋ค.
import React from 'react';
const LoginPage = () => {
const handleKakaoLoginClick = () => {
window.location.href = 'http://localhost:8080/oauth/kakao';
};
const handleNaverLoginClick = () => {
window.location.href = 'http://localhost:8080/oauth/naver'; // Replace with your Naver login URL
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<div>
<button
onClick={handleKakaoLoginClick}
style={{ padding: '10px 20px', fontSize: '18px', borderRadius: '5px', cursor: 'pointer', marginRight: '10px' }}
>
์นด์นด์คํก ๋ก๊ทธ์ธ
</button>
<button
onClick={handleNaverLoginClick}
style={{ padding: '10px 20px', fontSize: '18px', borderRadius: '5px', cursor: 'pointer' }}
>
๋ค์ด๋ฒ ๋ก๊ทธ์ธ
</button>
</div>
</div>
);
};
export default LoginPage;
์งํํด ๋ณด๊ฒ ์ต๋๋ค.
์ด๋ ๊ฒ ํด์ ์ ๋ง ๊ฐ๋จํ๊ฒ Naver ๋ก๊ทธ์ธ๋ ์ถ๊ฐ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค!