Ver3.0 ๐ OAuth 2.0 ์ธ์ฆ๊ณผ ๋ณด์ ์ํ ์๋๋ฆฌ์ค

๐ OAuth 2.0 ์ธ์ฆ๊ณผ ๋ณด์ ์ํ ์๋๋ฆฌ์ค
ํ๋ ์น์ ํ์ ์ธ์ฆ ํ๋กํ ์ฝ, ๊ทธ ์ด๋ฉด์ ๋ณด์ ์ํ๊น์ง ํํค์ณ๋ณด์!
๐ฏ OAuth 2.0, ๋ ๋๊ตฌ๋?
์์ฆ ์น์ฌ์ดํธ๋ ์ฑ์ ๊ฐ์
ํ ๋ "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ", "์นด์นด์ค๋ก ์์ํ๊ธฐ" ๊ฐ์ ๋ฒํผ ๋ง์ด ๋ณด์ง? ๐ค
์ด๊ฒ ๋ฐ๋ก OAuth 2.0์ด ์๋ํ๋ ์๊ฐ์ด์ผ. ๋น๋ฐ๋ฒํธ๋ฅผ ์ง์ ์
๋ ฅํ์ง ์๊ณ ๋ ๋ค๋ฅธ ์๋น์ค์ ์ธ์ฆ์ ๋น๋ ค์ ๋ก๊ทธ์ธํ ์ ์๊ฒ ํด์ฃผ๋ ๋ง๋ฒ ๊ฐ์ ํ๋กํ ์ฝ์ด์ง.
2012๋
์ ์ ์์ผ๋ก ํ์คํ๋ OAuth 2.0์ ์ด์ ๋ฒ์ ์ธ OAuth 1.0์ ๋ณต์กํจ์ ๋ํญ ๊ฐ์ ํ์ด. ์ธ์ฆ(Authentication)๊ณผ ์ธ๊ฐ(Authorization)๋ฅผ ๋ถ๋ฆฌํด์ ์ฒ๋ฆฌํ๋ ๊ฒ ํต์ฌ์ด์ผ.
์ฝ๊ฒ ๋งํ๋ฉด, "๋ ๋๊ตฌ์ผ?"๋ฅผ ํ์ธํ๋ ๊ฒ ์ธ์ฆ์ด๊ณ , "๋ ์ด๊ฑฐ ํด๋ ๋ผ?"๋ฅผ ํ์ธํ๋ ๊ฒ ์ธ๊ฐ์ผ. OAuth 2.0์ ์ฃผ๋ก ์ธ๊ฐ์ ์ง์คํ๋ ํ๋ ์์ํฌ์ง๋ง, ์ค์ ๋ก๋ ์ธ์ฆ ์ฉ๋๋ก๋ ๊ด๋ฒ์ํ๊ฒ ์ฌ์ฉ๋๊ณ ์์ด. ๐ญ
๐๏ธ OAuth 2.0์ ํต์ฌ ๊ตฌ์ฑ ์์
OAuth 2.0์ ์ดํดํ๋ ค๋ฉด ๋จผ์ ๋ฑ์ฅ์ธ๋ฌผ๋ค์ ์์์ผ ํด. ๋ง์น ์ฐ๊ทน ๋ฌด๋ ์์ ๋ฐฐ์ฐ๋ค์ฒ๋ผ, ๊ฐ์์ ์ญํ ์ด ๋ช ํํ๊ฒ ์ ํด์ ธ ์๊ฑฐ๋ . ๐ฌ
๋ฐ๋ก ์ฌ์ฉ์, ์ฆ ๋์ผ! ๐ค
๊ตฌ๊ธ ๊ณ์ ์ ์ ์ฅ๋ ํ๋กํ ์ ๋ณด, ์ฌ์ง, ์ด๋ฉ์ผ ๋ฑ์ ๋ฆฌ์์ค๋ฅผ ์ค์ ๋ก ์์ ํ๊ณ ์๋ ์ฃผ์ฒด์ง.
๋ค๋ฅธ ์ ํ๋ฆฌ์ผ์ด์
์ด ์ด ์ ๋ณด์ ์ ๊ทผํ๋ ๊ฑธ ํ๋ฝํ ์ง ๋ง์ง ๊ฒฐ์ ํ๋ ๊ถํ์ ๊ฐ์ง๊ณ ์์ด.
๋ฆฌ์์ค์ ์ ๊ทผํ๊ณ ์ถ์ด ํ๋ ์ ํ๋ฆฌ์ผ์ด์
์ด์ผ. ๐ฑ
์๋ฅผ ๋ค์ด, ๊ตฌ๊ธ ๊ณ์ ์ผ๋ก ๋ก๊ทธ์ธํ๋ ค๋ ์ด๋ค ์ผํ๋ชฐ ์น์ฌ์ดํธ๊ฐ ํด๋ผ์ด์ธํธ๊ฐ ๋๋ ๊ฑฐ์ง.
ํด๋ผ์ด์ธํธ๋ ์ฌ์ฉ์๋ฅผ ๋์ ํด์ ๋ฆฌ์์ค ์๋ฒ์ ์ ๊ทผํ๋ ค๊ณ ํ์ง๋ง, ์ง์ ์ ์ผ๋ก๋ ๋ถ๊ฐ๋ฅํด. ๋ฐ๋์ ์ฌ์ฉ์์ ํ๋ฝ์ ๋ฐ์์ผ ํ๊ฑฐ๋ .
์ฌ์ฉ์์ ์ ์์ ํ์ธํ๊ณ , ์ก์ธ์ค ํ ํฐ์ ๋ฐ๊ธํ๋ ์๋ฒ์ผ. ๐ซ
๊ตฌ๊ธ ๋ก๊ทธ์ธ์ ๊ฒฝ์ฐ ๊ตฌ๊ธ์ ์ธ์ฆ ์๋ฒ๊ฐ ์ด ์ญํ ์ ๋ด๋นํด.
์ฌ์ฉ์๊ฐ "์ด ์ฑ์ด ๋ด ์ ๋ณด์ ์ ๊ทผํ๋ ๊ฑธ ํ๋ฝํฉ๋๋ค"๋ผ๊ณ ๋์ํ๋ฉด, ์ด ์๋ฒ๊ฐ ํด๋ผ์ด์ธํธ์๊ฒ ์ก์ธ์ค ํ ํฐ์ด๋ผ๋ ์ผ์ข
์ ์ถ์
์ฆ์ ๋ฐ๊ธํด์ฃผ๋ ๊ฑฐ์ง.
์ค์ ์ฌ์ฉ์์ ์ ๋ณด๊ฐ ์ ์ฅ๋์ด ์๋ ์๋ฒ์ผ. ๐๏ธ
ํด๋ผ์ด์ธํธ๊ฐ ์ ํจํ ์ก์ธ์ค ํ ํฐ์ ์ ์ํ๋ฉด, ์์ฒญํ ๋ฆฌ์์ค๋ฅผ ์ ๊ณตํด์ค.
๋ง์ ๊ฒฝ์ฐ ์ธ์ฆ ์๋ฒ์ ๋ฆฌ์์ค ์๋ฒ๊ฐ ๊ฐ์ ์์คํ
๋ด์ ์์ง๋ง, ๋
ผ๋ฆฌ์ ์ผ๋ก๋ ๋ถ๋ฆฌ๋ ๊ฐ๋
์ด์ผ.
โข Access Token (์ก์ธ์ค ํ ํฐ): ๋ฆฌ์์ค ์๋ฒ์ ์ ๊ทผํ ์ ์๋ ๊ถํ์ ๋ํ๋ด๋ ๋ฌธ์์ด. ๋ณดํต ์งง์ ์ ํจ๊ธฐ๊ฐ์ ๊ฐ์ ธ.
โข Refresh Token (๋ฆฌํ๋ ์ ํ ํฐ): ์ก์ธ์ค ํ ํฐ์ด ๋ง๋ฃ๋์์ ๋ ์๋ก์ด ์ก์ธ์ค ํ ํฐ์ ๋ฐ๊ธ๋ฐ๊ธฐ ์ํ ํ ํฐ. ์๋์ ์ผ๋ก ๊ธด ์ ํจ๊ธฐ๊ฐ์ ๊ฐ์ ธ.
๐ญ OAuth 2.0์ 4๊ฐ์ง ๊ถํ ๋ถ์ฌ ๋ฐฉ์
OAuth 2.0์ ์ํฉ์ ๋ฐ๋ผ ๋ค๋ฅธ ์ธ์ฆ ๋ฐฉ์์ ์ ๊ณตํด. ๋ง์น ์ํฉ์ ๋ง๋ ์ท์ ๊ณจ๋ผ ์
๋ฏ์ด ๋ง์ด์ผ. ๐
๊ฐ ๋ฐฉ์์ ๋ณด์ ์์ค๊ณผ ์ฌ์ฉ ํธ์์ฑ ์ฌ์ด์ ํธ๋ ์ด๋์คํ๋ฅผ ๊ณ ๋ คํด์ ์ค๊ณ๋์์ด.
๊ฐ์ฅ ์์ ํ๊ณ ๋๋ฆฌ ์ฌ์ฉ๋๋ ๋ฐฉ์์ด์ผ. ๐ก๏ธ
์น ์ ํ๋ฆฌ์ผ์ด์
์์ ์ฃผ๋ก ์ฌ์ฉ๋๋ฉฐ, ์๋ฒ ์ฌ์ด๋์์ ํด๋ผ์ด์ธํธ ์ํฌ๋ฆฟ์ ์์ ํ๊ฒ ๋ณด๊ดํ ์ ์์ ๋ ์ ํฉํด.
2. ํด๋ผ์ด์ธํธ๊ฐ ์ฌ์ฉ์๋ฅผ ์ธ์ฆ ์๋ฒ์ ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ
3. ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธํ๊ณ ๊ถํ ๋ถ์ฌ์ ๋์
4. ์ธ์ฆ ์๋ฒ๊ฐ authorization code๋ฅผ ํด๋ผ์ด์ธํธ์๊ฒ ์ ๋ฌ (๋ฆฌ๋ค์ด๋ ํธ URI๋ฅผ ํตํด)
5. ํด๋ผ์ด์ธํธ๊ฐ ์ด ์ฝ๋์ ํด๋ผ์ด์ธํธ ์ํฌ๋ฆฟ์ ์ธ์ฆ ์๋ฒ์ ์ ์ถ
6. ์ธ์ฆ ์๋ฒ๊ฐ ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธ
7. ํด๋ผ์ด์ธํธ๊ฐ ์ก์ธ์ค ํ ํฐ์ผ๋ก ๋ฆฌ์์ค ์๋ฒ์ ์ ๊ทผ
์ด ๋ฐฉ์์ ํต์ฌ์ ์ก์ธ์ค ํ ํฐ์ด ์ฌ์ฉ์์ ๋ธ๋ผ์ฐ์ ๋ฅผ ๊ฑฐ์น์ง ์๋๋ค๋ ๊ฑฐ์ผ. ๐
์ธ๊ฐ ์ฝ๋๋ง ๋ธ๋ผ์ฐ์ ๋ฅผ ํตํด ์ ๋ฌ๋๊ณ , ์ค์ ํ ํฐ ๊ตํ์ ์๋ฒ ๊ฐ ์ง์ ํต์ ์ผ๋ก ์ด๋ฃจ์ด์ ธ์ ํจ์ฌ ์์ ํด.
// ์ธ๊ฐ ์ฝ๋ ์์ฒญ ์์ (URL)
https://authorization-server.com/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://your-app.com/callback&
scope=read_profile&
state=random_string_for_csrf_protection
// ํ ํฐ ๊ตํ ์์ฒญ ์์ (์๋ฒ ์ฌ์ด๋)
POST /token HTTP/1.1
Host: authorization-server.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://your-app.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
์ฃผ๋ก ๋ธ๋ผ์ฐ์ ๊ธฐ๋ฐ ์ฑ์ด๋ ๋ชจ๋ฐ์ผ ์ฑ์์ ์ฌ์ฉ๋๋ ๋ฐฉ์์ด์ผ. ๐ฒ
ํ์ง๋ง ๋ณด์ ์ทจ์ฝ์ ๋๋ฌธ์ ํ์ฌ๋ ์ฌ์ฉ์ด ๊ถ์ฅ๋์ง ์์. ๋์ PKCE๋ฅผ ์ฌ์ฉํ Authorization Code ๋ฐฉ์์ ์ฐ๋ ๊ฒ ์ข์.
์ฌ์ฉ์๊ฐ ํด๋ผ์ด์ธํธ ์ฑ์ ์ง์ ์์ด๋์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ๋ ๋ฐฉ์์ด์ผ. ๐
์ด๊ฑด ํด๋ผ์ด์ธํธ๋ฅผ ์์ ํ ์ ๋ขฐํ ์ ์์ ๋๋ง ์ฌ์ฉํด์ผ ํด. ์๋ฅผ ๋ค์ด, ์ด์์ฒด์ ์์ฒด ์ฑ์ด๋ ๊ฐ์ ํ์ฌ์์ ๋ง๋ ๊ณต์ ์ฑ ๊ฐ์ ๊ฒฝ์ฐ์ง.
// ํ ํฐ ์์ฒญ ์์
POST /token HTTP/1.1
Host: authorization-server.com
Content-Type: application/x-www-form-urlencoded
grant_type=password&
username=user@example.com&
password=user_password&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
์ฌ์ฉ์๊ฐ ์๋ ์ ํ๋ฆฌ์ผ์ด์
์์ฒด๊ฐ ๋ฆฌ์์ค ์์ ์์ธ ๊ฒฝ์ฐ์ ์ฌ์ฉํด. ๐ค
์๋ฒ ๊ฐ ํต์ ์ด๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ์์
์ ์ ํฉํ์ง. ์๋ฅผ ๋ค์ด, ๋ง์ดํฌ๋ก์๋น์ค ๊ฐ API ํธ์ถ ๊ฐ์ ๊ฒฝ์ฐ์ผ.
// ํ ํฐ ์์ฒญ ์์
POST /token HTTP/1.1
Host: authorization-server.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=api_access
์ด ๋ฐฉ์์ ์ฌ์ฉ์ ์ปจํ ์คํธ๊ฐ ์๊ธฐ ๋๋ฌธ์ ๋ฆฌํ๋ ์ ํ ํฐ์ด ๋ฐ๊ธ๋์ง ์์. ์ก์ธ์ค ํ ํฐ์ด ๋ง๋ฃ๋๋ฉด ๋ค์ ์์ฒญํ๋ฉด ๋ผ.
๐ OAuth 2.0์ ๋ณด์ ๊ฐํ ๊ธฐ์ : PKCE
๋ชจ๋ฐ์ผ ์ฑ์ด๋ SPA(Single Page Application)์์ Authorization Code ๋ฐฉ์์ ์์ ํ๊ฒ ์ฌ์ฉํ๊ธฐ ์ํด PKCE(Proof Key for Code Exchange)๊ฐ ๋ฑ์ฅํ์ด. ๐
๋ฐ์์ "ํฝ์"๋ผ๊ณ ํด. ๊ท์ฝ์ง? ํ์ง๋ง ๋ณด์์ ๊ฐ๋ ฅํด!
PKCE ๋์ ๋ฐฉ์: โ๏ธ
2. ์ด๋ฅผ SHA256์ผ๋ก ํด์ฑํ์ฌ code_challenge ์์ฑ
3. ์ธ๊ฐ ์์ฒญ ์ code_challenge๋ฅผ ํจ๊ป ์ ์ก
4. ์ธ์ฆ ์๋ฒ๊ฐ code_challenge๋ฅผ ์ ์ฅ
5. ํ ํฐ ๊ตํ ์ ์๋ณธ code_verifier๋ฅผ ํจ๊ป ์ ์ก
6. ์ธ์ฆ ์๋ฒ๊ฐ code_verifier๋ฅผ ํด์ฑํ์ฌ ์ ์ฅ๋ code_challenge์ ๋น๊ต
7. ์ผ์นํ๋ฉด ํ ํฐ ๋ฐ๊ธ
// PKCE๋ฅผ ์ฌ์ฉํ ์ธ๊ฐ ์์ฒญ
https://authorization-server.com/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://your-app.com/callback&
scope=read_profile&
state=random_string&
code_challenge=HASHED_CODE_VERIFIER&
code_challenge_method=S256
// ํ ํฐ ๊ตํ ์
POST /token HTTP/1.1
Host: authorization-server.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://your-app.com/callback&
client_id=YOUR_CLIENT_ID&
code_verifier=ORIGINAL_RANDOM_STRING
๊ณต๊ฒฉ์๊ฐ ์ธ๊ฐ ์ฝ๋๋ฅผ ๊ฐ๋ก์ฑ๋ค ํด๋, code_verifier๋ฅผ ๋ชจ๋ฅด๋ฉด ํ ํฐ์ ๋ฐ๊ธ๋ฐ์ ์ ์์ด. ๐ก๏ธ
2021๋
OAuth 2.1 ๋๋ํํธ์์๋ PKCE๋ฅผ ๋ชจ๋ ํด๋ผ์ด์ธํธ์ ํ์๋ก ์ ์ฉํ๋๋ก ๊ถ์ฅํ๊ณ ์์ด.
โ ๏ธ OAuth 2.0์ ์ฃผ์ ๋ณด์ ์ํ ์๋๋ฆฌ์ค
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ์ด๋์ด ๋ฉด์ ๋ค์ฌ๋ค๋ณผ ์๊ฐ์ด์ผ. ๐ต๏ธ
OAuth 2.0์ ๊ฐ๋ ฅํ์ง๋ง, ์๋ชป ๊ตฌํํ๋ฉด ์ฌ๊ฐํ ๋ณด์ ์ทจ์ฝ์ ์ด ์๊ธธ ์ ์์ด. ์ค์ ๋ก ๋ง์ ์๋น์ค๋ค์ด ์ด๋ฐ ๋ฌธ์ ๋ก ํดํน๋นํ๊ฑฐ๋ .
๊ณต๊ฒฉ์๊ฐ ์ฌ์ฉ์์ ํด๋ผ์ด์ธํธ ์ฌ์ด์ ํต์ ์ ๊ฐ๋ก์ฑ์ ์ธ๊ฐ ์ฝ๋๋ฅผ ํ์น๋ ๊ณต๊ฒฉ์ด์ผ. ๐ฃ
2. ์ธ์ฆ ์๋ฒ๊ฐ ๋ฆฌ๋ค์ด๋ ํธ URI๋ก ์ธ๊ฐ ์ฝ๋ ์ ์ก
3. ๊ณต๊ฒฉ์๊ฐ ์ค๊ฐ์์ ์ด ๋ฆฌ๋ค์ด๋ ํธ๋ฅผ ๊ฐ๋ก์ฑ (์: ์ ์ฑ ์ฑ์ด ์ปค์คํ URL ์คํด ๊ฐ๋ก์ฑ๊ธฐ)
4. ๊ณต๊ฒฉ์๊ฐ ํ์น ์ธ๊ฐ ์ฝ๋๋ก ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธ ์๋
5. ์ฑ๊ณตํ๋ฉด ์ฌ์ฉ์์ ๋ฆฌ์์ค์ ๋ฌด๋จ ์ ๊ทผ
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข State ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ: CSRF ๊ณต๊ฒฉ ๋ฐฉ์ง ๋ฐ ์์ฒญ ๋ฌด๊ฒฐ์ฑ ํ์ธ
โข ์งง์ ์ธ๊ฐ ์ฝ๋ ์ ํจ๊ธฐ๊ฐ: ๋ณดํต 10๋ถ ์ด๋ด๋ก ์ค์
โข ์ผํ์ฉ ์ธ๊ฐ ์ฝ๋: ํ ๋ฒ ์ฌ์ฉ๋ ์ฝ๋๋ ์ฆ์ ๋ฌดํจํ
โข HTTPS ํ์ ์ฌ์ฉ: ๋ชจ๋ ํต์ ์ ์ํธํ
OAuth์ ๊ฐ์ฅ ์น๋ช
์ ์ธ ์ทจ์ฝ์ ์ค ํ๋์ผ. ๐
๊ณต๊ฒฉ์๊ฐ ๋ฆฌ๋ค์ด๋ ํธ URI๋ฅผ ์์ ์ ์๋ฒ๋ก ๋ณ๊ฒฝํด์ ์ธ๊ฐ ์ฝ๋๋ ํ ํฐ์ ๊ฐ๋ก์ฑ๋ ๊ณต๊ฒฉ์ด์ง.
1. ๊ณต๊ฒฉ์๊ฐ ์กฐ์๋ ์ธ๊ฐ ์์ฒญ URL ์์ฑ
redirect_uri=https://attacker.com/steal
2. ์ฌ์ฉ์๋ฅผ ํผ์ฑ ๋งํฌ๋ก ์ ๋
3. ์ฌ์ฉ์๊ฐ ์ ์์ ์ผ๋ก ๋ก๊ทธ์ธํ๊ณ ๊ถํ ๋ถ์ฌ
4. ์ธ๊ฐ ์ฝ๋๊ฐ ๊ณต๊ฒฉ์์ ์๋ฒ๋ก ์ ์ก๋จ
์๋๋ฆฌ์ค B - Path Traversal:
์ ์ URI: https://client.com/callback
์กฐ์ URI: https://client.com/callback/../../../attacker-page
๋๋: https://client.com/callback?redirect=https://attacker.com
์ค์ ์ฌ๋ก: ๐ฐ
2014๋
, ํ์ด์ค๋ถ์ OAuth ๊ตฌํ์์ ๋ฆฌ๋ค์ด๋ ํธ URI ๊ฒ์ฆ ์ทจ์ฝ์ ์ด ๋ฐ๊ฒฌ๋์์ด. ๊ณต๊ฒฉ์๊ฐ ํน์ ๋๋ฉ์ธ์ ์๋ธ๋๋ฉ์ธ์ผ๋ก ๋ฆฌ๋ค์ด๋ ํธํ ์ ์์๊ณ , ์ด๋ฅผ ํตํด ์ก์ธ์ค ํ ํฐ์ ํ์ทจํ ์ ์์์ง.
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข ํ์ดํธ๋ฆฌ์คํธ ๋ฐฉ์: ํ์ฉ๋ ๋ฆฌ๋ค์ด๋ ํธ URI ๋ชฉ๋ก์ ์ฌ์ ์ ๋ฑ๋ก
โข ํ๋กํ ์ฝ ๊ฒ์ฆ: HTTPS๋ง ํ์ฉ (๊ฐ๋ฐ ํ๊ฒฝ ์ ์ธ)
โข ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ: ๋ฆฌ๋ค์ด๋ ํธ URI์ ์ถ๊ฐ ํ๋ผ๋ฏธํฐ๊ฐ ๋ถ๋ ๊ฒ ์ ํ
โข ์ ๊ทํ ํ ๋น๊ต: URL ์ธ์ฝ๋ฉ, ๋์๋ฌธ์ ๋ฑ์ ์ ๊ทํํ ํ ๋น๊ต
// ์์ ํ ๋ฆฌ๋ค์ด๋ ํธ URI ๊ฒ์ฆ ์์ (Python)
from urllib.parse import urlparse
ALLOWED_REDIRECT_URIS = [
'https://myapp.com/callback',
'https://myapp.com/oauth/callback'
]
def validate_redirect_uri(redirect_uri):
# ์ ํํ ๋งค์นญ๋ง ํ์ฉ
if redirect_uri not in ALLOWED_REDIRECT_URIS:
raise ValueError("Invalid redirect URI")
# ์ถ๊ฐ ๋ณด์ ๊ฒ์ฆ
parsed = urlparse(redirect_uri)
if parsed.scheme != 'https':
raise ValueError("Only HTTPS allowed")
return True
์ก์ธ์ค ํ ํฐ์ด๋ ๋ฆฌํ๋ ์ ํ ํฐ์ด ์๋์น ์๊ฒ ์ธ๋ถ์ ๋
ธ์ถ๋๋ ๊ฒฝ์ฐ์ผ. ๐
์๊ฐ๋ณด๋ค ๋ค์ํ ๊ฒฝ๋ก๋ก ํ ํฐ์ด ์ ์ถ๋ ์ ์์ด.
Implicit Grant์์ ํ ํฐ์ด URL ํ๋๊ทธ๋จผํธ์ ํฌํจ๋์ด ๋ธ๋ผ์ฐ์ ํ์คํ ๋ฆฌ์ ์ ์ฅ
2. Referer ํค๋:
ํ ํฐ์ด ํฌํจ๋ ํ์ด์ง์์ ์ธ๋ถ ๋ฆฌ์์ค(์ด๋ฏธ์ง, ์คํฌ๋ฆฝํธ ๋ฑ) ๋ก๋ ์ Referer ํค๋๋ก ์ ์ถ
3. ๋ก๊ทธ ํ์ผ:
์๋ฒ ๋ก๊ทธ, ํ๋ก์ ๋ก๊ทธ, CDN ๋ก๊ทธ ๋ฑ์ ํ ํฐ์ด ๊ธฐ๋ก๋จ
4. ํด๋ผ์ด์ธํธ ์ ์ฅ์:
LocalStorage, SessionStorage์ ์ ์ฅ๋ ํ ํฐ์ด XSS ๊ณต๊ฒฉ์ผ๋ก ํ์ทจ
5. ๋คํธ์ํฌ ์ค๋ํ:
HTTPS๋ฅผ ์ฌ์ฉํ์ง ์๊ฑฐ๋ ์ธ์ฆ์ ๊ฒ์ฆ์ ํ์ง ์๋ ๊ฒฝ์ฐ
์ค์ ์ฌ๋ก: ๐ฐ
2018๋
, ํ ๋ํ ์์
๋ฏธ๋์ด ํ๋ซํผ์์ ์ก์ธ์ค ํ ํฐ์ด ์๋ฒ ๋ก๊ทธ์ ํ๋ฌธ์ผ๋ก ๊ธฐ๋ก๋๋ ๋ฒ๊ทธ๊ฐ ๋ฐ๊ฒฌ๋์์ด. ์๋ฐฑ๋ง ๋ช
์ ์ฌ์ฉ์ ํ ํฐ์ด ๋ด๋ถ ์์คํ
์ ๋
ธ์ถ๋์์ง.
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข ์งง์ ํ ํฐ ์ ํจ๊ธฐ๊ฐ: ์ก์ธ์ค ํ ํฐ์ 15๋ถ~1์๊ฐ ์ ๋๋ก ์ค์
โข Secure ํ๋๊ทธ: ์ฟ ํค๊ฐ HTTPS์์๋ง ์ ์ก๋๋๋ก ์ค์
โข SameSite ์์ฑ: CSRF ๊ณต๊ฒฉ ๋ฐฉ์ด
โข ํ ํฐ ๋ก๊น ๊ธ์ง: ๋ก๊ทธ์ ํ ํฐ์ด ๊ธฐ๋ก๋์ง ์๋๋ก ํํฐ๋ง
โข Referrer-Policy ํค๋: Referer ํค๋ ์ ์ก ์ ํ
โข ํ ํฐ ์ํธํ: ์ ์ฅ ์ ์ํธํ, ์ฌ์ฉ ์ ๋ณตํธํ
// ์์ ํ ํ ํฐ ์ ์ฅ ์์ (JavaScript)
// โ ๋์ ์: LocalStorage ์ฌ์ฉ
localStorage.setItem('access_token', token); // XSS์ ์ทจ์ฝ!
// โ
์ข์ ์: HttpOnly ์ฟ ํค ์ฌ์ฉ (์๋ฒ ์ฌ์ด๋)
Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict; Max-Age=3600
// ๋๋ ๋ฉ๋ชจ๋ฆฌ์๋ง ์ ์ฅ (SPA์ ๊ฒฝ์ฐ)
class TokenManager {
constructor() {
this._token = null; // ํด๋ก์ ๋ก ๋ณดํธ
}
setToken(token) {
this._token = token;
}
getToken() {
return this._token;
}
}
๊ณต๊ฒฉ์๊ฐ ์ฌ์ฉ์์ ๋ธ๋ผ์ฐ์ ๋ฅผ ์ด์ฉํด ์๋ํ์ง ์์ OAuth ์ธ์ฆ์ ์ํํ๊ฒ ๋ง๋๋ ๊ณต๊ฒฉ์ด์ผ. ๐ญ
2. ์ธ๊ฐ ์ฝ๋๋ฅผ ๋ฐ๋ ๋จ๊ณ์์ ํ๋ก์ธ์ค ์ค๋จ
3. ํผํด์๋ฅผ ์ ์ฑ ํ์ด์ง๋ก ์ ๋
4. ์ ์ฑ ํ์ด์ง๊ฐ ์๋์ผ๋ก ๊ณต๊ฒฉ์์ ์ธ๊ฐ ์ฝ๋๋ก ํ ํฐ ๊ตํ ์์ฒญ ์ ์ก
5. ํผํด์์ ์ธ์ ์ ๊ณต๊ฒฉ์์ ๊ณ์ ์ด ์ฐ๊ฒฐ๋จ
6. ํผํด์๊ฐ ์ ๋ ฅํ ์ ๋ณด(๊ฒฐ์ ์ ๋ณด, ๊ฐ์ธ์ ๋ณด ๋ฑ)๊ฐ ๊ณต๊ฒฉ์์ ๊ณ์ ์ ์ ์ฅ๋จ
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข ์ธ์ ๋ฐ์ธ๋ฉ: State ๊ฐ์ ์๋ฒ ์ธ์ ์ ์ ์ฅํ๊ณ ๊ฒ์ฆ
โข Nonce ์ฌ์ฉ: OpenID Connect์ ๊ฒฝ์ฐ nonce ํ๋ผ๋ฏธํฐ ํ์ฉ
โข ์งง์ ์ ํจ๊ธฐ๊ฐ: State ๊ฐ์ ํ์์คํฌํ ํฌํจ ๋ฐ ๊ฒ์ฆ
// State ํ๋ผ๋ฏธํฐ ์์ฑ ๋ฐ ๊ฒ์ฆ ์์ (Node.js)
const crypto = require('crypto');
// ์ธ๊ฐ ์์ฒญ ์
function generateAuthUrl() {
const state = crypto.randomBytes(32).toString('hex');
// ์ธ์
์ ์ ์ฅ
req.session.oauthState = state;
const authUrl = `https://authorization-server.com/auth?` +
`response_type=code&` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${REDIRECT_URI}&` +
`state=${state}`;
return authUrl;
}
// ์ฝ๋ฐฑ ์ฒ๋ฆฌ ์
function handleCallback(req, res) {
const receivedState = req.query.state;
const savedState = req.session.oauthState;
// State ๊ฒ์ฆ
if (!receivedState || receivedState !== savedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// State ์ผํ์ฉ์ผ๋ก ๋ง๋ค๊ธฐ
delete req.session.oauthState;
// ํ ํฐ ๊ตํ ์งํ...
}
๊ณต๊ฒฉ์๊ฐ ์ ์ ํด๋ผ์ด์ธํธ์ธ ์ฒ ์์ฅํด์ ์ฌ์ฉ์๋ฅผ ์์ด๋ ๊ณต๊ฒฉ์ด์ผ. ๐ช
1. ๊ณต๊ฒฉ์๊ฐ ์ ์ ์ฑ์ ํด๋ผ์ด์ธํธ ID๋ฅผ ์ถ์ถ (๋์ปดํ์ผ, ๋คํธ์ํฌ ๋ถ์ ๋ฑ)
2. ์ ์ฑ ์ฑ์ ๋์ผํ ํด๋ผ์ด์ธํธ ID ์ฌ์ฉ
3. ์ฌ์ฉ์๊ฐ ์ ์ ์ฑ์ผ๋ก ์ฐฉ๊ฐํ๊ณ ๊ถํ ๋ถ์ฌ
4. ๊ณต๊ฒฉ์๊ฐ ์ฌ์ฉ์์ ๋ฆฌ์์ค์ ์ ๊ทผ
์๋๋ฆฌ์ค B - ํผ์ฑ ์ฑ:
1. ๊ณต๊ฒฉ์๊ฐ ์ ์ ์ฑ๊ณผ ์ ์ฌํ ์ด๋ฆ/์์ด์ฝ์ ์ ์ฑ ์ฑ ๋ฐฐํฌ
2. ์ฌ์ฉ์๊ฐ ์ ์ฑ ์ฑ์์ OAuth ์ธ์ฆ ์งํ
3. ์ ์ฑ ์ฑ์ด ๊ณผ๋ํ ๊ถํ ์์ฒญ
4. ์ฌ์ฉ์ ์ ๋ณด ํ์ทจ
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข ํด๋ผ์ด์ธํธ ์ธ์ฆ ๊ฐํ: ํด๋ผ์ด์ธํธ ์ํฌ๋ฆฟ, ์ธ์ฆ์ ๊ธฐ๋ฐ ์ธ์ฆ ๋ฑ
โข ์ฑ ์๋ช ๊ฒ์ฆ: ๋ชจ๋ฐ์ผ ์ฑ์ ๊ฒฝ์ฐ ์ฑ ์๋ช ํ์ธ
โข ์ฌ์ฉ์ ๋์ ํ๋ฉด ๊ฐ์ : ์ด๋ค ์ฑ์ด ์ด๋ค ๊ถํ์ ์์ฒญํ๋์ง ๋ช ํํ ํ์
โข ๊ถํ ์ต์ํ ์์น: ํ์ํ ์ต์ํ์ ์ค์ฝํ๋ง ์์ฒญ
โข ์ฑ ๊ฒ์ฆ ํ๋ก์ธ์ค: ์ฑ ์คํ ์ด ๋ฑ๋ก ์ ์ฒ ์ ํ ๊ฒ์ฆ
๊ณต๊ฒฉ์๊ฐ ํ์ทจํ ํ ํฐ์ ๋ฐ๋ณต์ ์ผ๋ก ์ฌ์ฉํ๋ ๊ณต๊ฒฉ์ด์ผ. ๐
2. ํ ํฐ์ด ๋ง๋ฃ๋๊ธฐ ์ ๊น์ง ๋ฐ๋ณต์ ์ผ๋ก API ํธ์ถ
3. ์ฌ์ฉ์์ ๋ฐ์ดํฐ ์กฐํ, ์์ , ์ญ์ ๋ฑ ์ ์์ ํ์ ์ํ
4. ๋ฆฌํ๋ ์ ํ ํฐ๊น์ง ํ์ทจํ ๊ฒฝ์ฐ ์ฅ๊ธฐ๊ฐ ์ ๊ทผ ๊ฐ๋ฅ
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข IP ์ฃผ์ ๊ฒ์ฆ: ํ ํฐ ๋ฐ๊ธ ์ IP์ ์ฌ์ฉ ์ IP ๋น๊ต
โข User-Agent ๊ฒ์ฆ: ๋ธ๋ผ์ฐ์ /๋๋ฐ์ด์ค ์ ๋ณด ํ์ธ
โข ์ด์ ํ์ ํ์ง: ๋น์ ์์ ์ธ API ํธ์ถ ํจํด ๋ชจ๋ํฐ๋ง
โข ํ ํฐ ํ์ : ๋ฆฌํ๋ ์ ํ ํฐ ์ฌ์ฉ ์๋ง๋ค ์๋ก์ด ํ ํฐ ๋ฐ๊ธ
โข ํ ํฐ ์ทจ์ ๋ฉ์ปค๋์ฆ: ์์ฌ์ค๋ฌ์ด ํ๋ ๊ฐ์ง ์ ์ฆ์ ํ ํฐ ๋ฌดํจํ
// ํ ํฐ ๋ฐ์ธ๋ฉ ์์ (JWT ์ฌ์ฉ)
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// ํ ํฐ ๋ฐ๊ธ ์
function issueToken(userId, req) {
// ํด๋ผ์ด์ธํธ ํ๊ฑฐํ๋ฆฐํธ ์์ฑ
const fingerprint = crypto.createHash('sha256')
.update(req.ip + req.headers['user-agent'])
.digest('hex');
const token = jwt.sign(
{
sub: userId,
fingerprint: fingerprint,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600
},
SECRET_KEY
);
return token;
}
// ํ ํฐ ๊ฒ์ฆ ์
function verifyToken(token, req) {
const decoded = jwt.verify(token, SECRET_KEY);
// ํ๊ฑฐํ๋ฆฐํธ ์ฌ์์ฑ ๋ฐ ๋น๊ต
const currentFingerprint = crypto.createHash('sha256')
.update(req.ip + req.headers['user-agent'])
.digest('hex');
if (decoded.fingerprint !== currentFingerprint) {
throw new Error('Token binding validation failed');
}
return decoded;
}
๊ณต๊ฒฉ์๊ฐ ์์ฒญํ๋ ๊ถํ ๋ฒ์๋ฅผ ์กฐ์ํด์ ๊ณผ๋ํ ๊ถํ์ ์ป์ผ๋ ค๋ ๊ณต๊ฒฉ์ด์ผ. ๐๏ธ
2. ๊ณต๊ฒฉ์๊ฐ ์์ฒญ์ ๊ฐ๋ก์ฑ์ scope=read_profile write_data delete_account๋ก ๋ณ๊ฒฝ
3. ์ฌ์ฉ์๊ฐ ํ์ธํ์ง ์๊ณ ์น์ธ
4. ์ฑ์ด ์๋ํ์ง ์์ ๊ณผ๋ํ ๊ถํ ํ๋
5. ์ ์์ ํ์ ์ํ
๋ฐฉ์ด ๋ฐฉ๋ฒ: ๐ก๏ธ
โข ๋์ ํ๋ฉด ๋ช ํํ: ๊ฐ ์ค์ฝํ๊ฐ ์๋ฏธํ๋ ๋ฐ๋ฅผ ์ฌ์ฉ์์๊ฒ ๋ช ํํ ํ์
โข ์ต์ ๊ถํ ์์น: ํ์ํ ์ต์ํ์ ์ค์ฝํ๋ง ์์ฒญ
โข ์ค์ฝํ ๊ฒ์ฆ: ํ ํฐ ์ฌ์ฉ ์ ์์ฒญ๋ ๋ฆฌ์์ค์ ๋ํ ์ค์ฝํ ํ์ธ
โข ์ธ๋ถํ๋ ์ค์ฝํ: ์ฝ๊ธฐ/์ฐ๊ธฐ/์ญ์ ๋ฑ์ ๋ณ๋ ์ค์ฝํ๋ก ๋ถ๋ฆฌ
๐ก๏ธ OAuth 2.0 ๋ณด์ ๋ชจ๋ฒ ์ฌ๋ก
์ง๊ธ๊น์ง ๋ค์ํ ์ํ์ ์ดํด๋ดค์ผ๋, ์ด์ ์ด๋ฅผ ์ข ํฉํด์ ์ค์ ์์ ์ ์ฉํ ์ ์๋ ๋ณด์ ๊ฐ์ด๋๋ฅผ ์ ๋ฆฌํด๋ณผ๊ฒ. ๐
โข ๋ฆฌ๋ค์ด๋ ํธ URI๋ฅผ ์ ํํ ๋ฑ๋กํ๊ณ ์๊ฒฉํ๊ฒ ๊ฒ์ฆ
โข ํด๋ผ์ด์ธํธ๋ณ ํ์ฉ ์ค์ฝํ ํ์ดํธ๋ฆฌ์คํธ ๊ด๋ฆฌ
โข ํด๋ผ์ด์ธํธ ํ์ (Public/Confidential) ๊ตฌ๋ถ ๋ฐ ์ ์ ํ ์ธ์ฆ ๋ฐฉ์ ์ ์ฉ
โ ํ ํฐ ๊ด๋ฆฌ
โข ์ก์ธ์ค ํ ํฐ ์ ํจ๊ธฐ๊ฐ์ ์งง๊ฒ ์ค์ (15๋ถ~1์๊ฐ)
โข ๋ฆฌํ๋ ์ ํ ํฐ ํ์ (Rotation) ๊ตฌํ
โข ํ ํฐ ์ทจ์(Revocation) ์๋ํฌ์ธํธ ์ ๊ณต
โข JWT ์ฌ์ฉ ์ ์ ์ ํ ์๊ณ ๋ฆฌ์ฆ ์ ํ (RS256 ๊ถ์ฅ, HS256์ ์ ์คํ)
โข ํ ํฐ์ ๋ฏผ๊ฐํ ์ ๋ณด ํฌํจ ๊ธ์ง
โ ๋ณด์ ๊ธฐ๋ฅ
โข PKCE ํ์ ์ ์ฉ (๋ชจ๋ ํด๋ผ์ด์ธํธ)
โข State ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ ๊ฐ์
โข Rate Limiting ๊ตฌํ (๋ฌด์ฐจ๋ณ ๋์ ๊ณต๊ฒฉ ๋ฐฉ์ด)
โข ์ด์ ํ์ ํ์ง ๋ฐ ๋ก๊น
โข HTTPS ๊ฐ์ (๊ฐ๋ฐ ํ๊ฒฝ ์ ์ธ)
โข State ํ๋ผ๋ฏธํฐ ์์ฑ ๋ฐ ๊ฒ์ฆ
โข ๋ฆฌ๋ค์ด๋ ํธ URI ํ๋์ฝ๋ฉ (๋์ ์์ฑ ๊ธ์ง)
โข ์ธ๊ฐ ์ฝ๋๋ ์ฆ์ ํ ํฐ์ผ๋ก ๊ตํ (์ฌ์ฌ์ฉ ๊ธ์ง)
โ ํ ํฐ ์ ์ฅ ๋ฐ ๊ด๋ฆฌ
โข ์น: HttpOnly, Secure, SameSite ์ฟ ํค ์ฌ์ฉ
โข SPA: ๋ฉ๋ชจ๋ฆฌ ์ ์ฅ ๋๋ BFF(Backend For Frontend) ํจํด
โข ๋ชจ๋ฐ์ผ: ์์คํ ํค์ฒด์ธ/ํค์คํ ์ด ํ์ฉ
โข LocalStorage/SessionStorage ์ฌ์ฉ ์์
โข ํ ํฐ ๋ง๋ฃ ์ ์๋ ๊ฐฑ์ ๋ก์ง ๊ตฌํ
โ API ํธ์ถ
โข Authorization ํค๋๋ก ํ ํฐ ์ ์ก (URL ํ๋ผ๋ฏธํฐ ๊ธ์ง)
โข ํ์ํ ์ต์ํ์ ์ค์ฝํ๋ง ์์ฒญ
โข API ์๋ต ๊ฒ์ฆ ๋ฐ ์๋ฌ ์ฒ๋ฆฌ
โข ๋คํธ์ํฌ ํ์์์ ์ค์
โข ํ ํฐ ์๋ช ๊ฒ์ฆ (JWT์ ๊ฒฝ์ฐ)
โข ํ ํฐ ๋ง๋ฃ ์๊ฐ ํ์ธ
โข ์ค์ฝํ ๊ฒ์ฆ (์์ฒญ๋ ๋ฆฌ์์ค์ ๋ํ ๊ถํ ํ์ธ)
โข ํ ํฐ ์ทจ์ ๋ชฉ๋ก(Blacklist) ํ์ธ
โ API ๋ณด์
โข HTTPS ํ์
โข CORS ์ ์ฑ ์ ์ ํ ์ค์
โข Rate Limiting ์ ์ฉ
โข ์ ๋ ฅ ๊ฐ ๊ฒ์ฆ ๋ฐ sanitization
โข ์๋ฌ ๋ฉ์์ง์ ๋ฏผ๊ฐํ ์ ๋ณด ํฌํจ ๊ธ์ง
๐ป ์ค์ ๊ตฌํ ์์
์ด๋ก ๋ง ์์์๋ ๋ถ์กฑํ์ง. ์ค์ ๋ก ์ด๋ป๊ฒ ๊ตฌํํ๋์ง ์ฝ๋๋ก ์ดํด๋ณด์! ๐จโ๐ป
์ฌ๊ธฐ์๋ Node.js/Express๋ฅผ ์ฌ์ฉํ ์์ ๋ฅผ ๋ณด์ฌ์ค๊ฒ.
// ํ์ํ ํจํค์ง ์ค์น
// npm install express express-session crypto axios
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const axios = require('axios');
const app = express();
// ์ธ์
์ค์
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS์์๋ง ์ ์ก
httpOnly: true, // JavaScript ์ ๊ทผ ์ฐจ๋จ
sameSite: 'lax', // CSRF ๋ฐฉ์ด
maxAge: 3600000 // 1์๊ฐ
}
}));
// OAuth ์ค์
const OAUTH_CONFIG = {
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
authorizationUrl: 'https://oauth-provider.com/authorize',
tokenUrl: 'https://oauth-provider.com/token',
redirectUri: 'https://your-app.com/callback',
scope: 'read_profile read_email'
};
// PKCE ํฌํผ ํจ์
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(verifier) {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// State ์์ฑ ํจ์
function generateState() {
return crypto.randomBytes(32).toString('hex');
}
// 1. ๋ก๊ทธ์ธ ์์ - ์ธ๊ฐ ์์ฒญ
app.get('/login', (req, res) => {
// PKCE ํ๋ผ๋ฏธํฐ ์์ฑ
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// State ์์ฑ
const state = generateState();
// ์ธ์
์ ์ ์ฅ (๊ฒ์ฆ์ฉ)
req.session.oauthState = state;
req.session.codeVerifier = codeVerifier;
// ์ธ๊ฐ URL ์์ฑ
const params = new URLSearchParams({
response_type: 'code',
client_id: OAUTH_CONFIG.clientId,
redirect_uri: OAUTH_CONFIG.redirectUri,
scope: OAUTH_CONFIG.scope,
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
const authUrl = `${OAUTH_CONFIG.authorizationUrl}?${params}`;
// ์ฌ์ฉ์๋ฅผ ์ธ์ฆ ์๋ฒ๋ก ๋ฆฌ๋ค์ด๋ ํธ
res.redirect(authUrl);
});
// 2. ์ฝ๋ฐฑ ์ฒ๋ฆฌ - ํ ํฐ ๊ตํ
app.get('/callback', async (req, res) => {
try {
const { code, state } = req.query;
// State ๊ฒ์ฆ (CSRF ๋ฐฉ์ด)
if (!state || state !== req.session.oauthState) {
throw new Error('Invalid state parameter');
}
// State ์ผํ์ฉ์ผ๋ก ๋ง๋ค๊ธฐ
delete req.session.oauthState;
// ์ธ๊ฐ ์ฝ๋ ํ์ธ
if (!code) {
throw new Error('Authorization code not received');
}
// ํ ํฐ ๊ตํ ์์ฒญ
const tokenResponse = await axios.post(
OAUTH_CONFIG.tokenUrl,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: OAUTH_CONFIG.redirectUri,
client_id: OAUTH_CONFIG.clientId,
client_secret: OAUTH_CONFIG.clientSecret,
code_verifier: req.session.codeVerifier
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Code verifier ์ญ์
delete req.session.codeVerifier;
const { access_token, refresh_token, expires_in } = tokenResponse.data;
// ํ ํฐ์ ์์ ํ๊ฒ ์ ์ฅ
// ์ค์ ํ๋ก๋์
์์๋ ์ํธํ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ
req.session.accessToken = access_token;
req.session.refreshToken = refresh_token;
req.session.tokenExpiry = Date.now() + (expires_in * 1000);
// ์ฌ์ฉ์ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
const userInfo = await getUserInfo(access_token);
req.session.user = userInfo;
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth callback error:', error.message);
res.status(500).send('Authentication failed');
}
});
// 3. ์ฌ์ฉ์ ์ ๋ณด ์กฐํ
async function getUserInfo(accessToken) {
try {
const response = await axios.get(
'https://oauth-provider.com/userinfo',
{
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
return response.data;
} catch (error) {
throw new Error('Failed to fetch user info');
}
}
// 4. ํ ํฐ ๊ฐฑ์
async function refreshAccessToken(refreshToken) {
try {
const response = await axios.post(
OAUTH_CONFIG.tokenUrl,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CONFIG.clientId,
client_secret: OAUTH_CONFIG.clientSecret
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
} catch (error) {
throw new Error('Token refresh failed');
}
}
// 5. ์ธ์ฆ ๋ฏธ๋ค์จ์ด
async function requireAuth(req, res, next) {
if (!req.session.accessToken) {
return res.redirect('/login');
}
// ํ ํฐ ๋ง๋ฃ ํ์ธ
if (Date.now() >= req.session.tokenExpiry) {
try {
// ํ ํฐ ๊ฐฑ์ ์๋
const newTokens = await refreshAccessToken(req.session.refreshToken);
req.session.accessToken = newTokens.access_token;
req.session.tokenExpiry = Date.now() + (newTokens.expires_in * 1000);
if (newTokens.refresh_token) {
req.session.refreshToken = newTokens.refresh_token;
}
} catch (error) {
// ๊ฐฑ์ ์คํจ ์ ์ฌ๋ก๊ทธ์ธ ํ์
delete req.session.accessToken;
delete req.session.refreshToken;
return res.redirect('/login');
}
}
next();
}
// 6. ๋ณดํธ๋ ๋ผ์ฐํธ
app.get('/dashboard', requireAuth, (req, res) => {
res.send(`
<h1>Welcome, ${req.session.user.name}!</h1>
<p>Email: ${req.session.user.email}</p>
<a href="/logout">Logout</a>
`);
});
// 7. ๋ก๊ทธ์์
app.get('/logout', async (req, res) => {
// ํ ํฐ ์ทจ์ (OAuth ์ ๊ณต์๊ฐ ์ง์ํ๋ ๊ฒฝ์ฐ)
if (req.session.accessToken) {
try {
await axios.post(
'https://oauth-provider.com/revoke',
new URLSearchParams({
token: req.session.accessToken,
client_id: OAUTH_CONFIG.clientId,
client_secret: OAUTH_CONFIG.clientSecret
})
);
} catch (error) {
console.error('Token revocation failed:', error.message);
}
}
// ์ธ์
์ญ์
req.session.destroy();
res.redirect('/');
});
app.listen(3000, () => {
console.log('Server running on https://localhost:3000');
});
โข PKCE๋ก ์ธ๊ฐ ์ฝ๋ ๊ฐ๋ก์ฑ๊ธฐ ๋ฐฉ์ด
โข State ํ๋ผ๋ฏธํฐ๋ก CSRF ๊ณต๊ฒฉ ๋ฐฉ์ด
โข HttpOnly ์ฟ ํค๋ก XSS ๊ณต๊ฒฉ ๋ฐฉ์ด
โข ํ ํฐ ์๋ ๊ฐฑ์ ๊ธฐ๋ฅ
โข ์์ ํ ํ ํฐ ์ ์ฅ (์ธ์ ์ฌ์ฉ)
// JWT ๊ฒ์ฆ ์์
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// JWKS ํด๋ผ์ด์ธํธ ์ค์ (๊ณต๊ฐํค ๊ฐ์ ธ์ค๊ธฐ)
const client = jwksClient({
jwksUri: 'https://oauth-provider.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 86400000 // 24์๊ฐ
});
// ๊ณต๊ฐํค ๊ฐ์ ธ์ค๊ธฐ ํจ์
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
} else {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
}
});
}
// JWT ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด
function verifyJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
// JWT ๊ฒ์ฆ
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'],
issuer: 'https://oauth-provider.com',
audience: 'your-api-identifier'
},
(err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// ์ค์ฝํ ๊ฒ์ฆ
const requiredScope = 'read:data';
const tokenScopes = decoded.scope ? decoded.scope.split(' ') : [];
if (!tokenScopes.includes(requiredScope)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
// ์ถ๊ฐ ๋ณด์ ๊ฒ์ฆ
// 1. ํ ํฐ ์ทจ์ ๋ชฉ๋ก ํ์ธ
if (isTokenRevoked(decoded.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
// 2. ํด๋ผ์ด์ธํธ ๊ฒ์ฆ
if (!isValidClient(decoded.client_id)) {
return res.status(401).json({ error: 'Invalid client' });
}
// ๊ฒ์ฆ ์ฑ๊ณต - ์ฌ์ฉ์ ์ ๋ณด ์ ์ฅ
req.user = {
id: decoded.sub,
clientId: decoded.client_id,
scopes: tokenScopes
};
next();
}
);
}
// ํ ํฐ ์ทจ์ ๋ชฉ๋ก ํ์ธ (Redis ๋ฑ ์ฌ์ฉ)
function isTokenRevoked(jti) {
// ์ค์ ๊ตฌํ์์๋ Redis๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์กฐํ
// return redisClient.exists(`revoked:${jti}`);
return false;
}
// ํด๋ผ์ด์ธํธ ๊ฒ์ฆ
function isValidClient(clientId) {
// ๋ฑ๋ก๋ ํด๋ผ์ด์ธํธ์ธ์ง ํ์ธ
const validClients = ['client-1', 'client-2'];
return validClients.includes(clientId);
}
// ๋ณดํธ๋ API ์๋ํฌ์ธํธ
app.get('/api/data', verifyJWT, (req, res) => {
// ์ฌ์ฉ์๋ณ ๋ฐ์ดํฐ ์กฐํ
const data = getUserData(req.user.id);
res.json(data);
});
// ์ค์ฝํ๋ณ ๊ถํ ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด
function requireScope(scope) {
return (req, res, next) => {
if (!req.user || !req.user.scopes.includes(scope)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// ์ฐ๊ธฐ ๊ถํ์ด ํ์ํ ์๋ํฌ์ธํธ
app.post('/api/data', verifyJWT, requireScope('write:data'), (req, res) => {
// ๋ฐ์ดํฐ ์์ฑ
const result = createData(req.user.id, req.body);
res.json(result);
});
๐ OAuth 2.0 vs OpenID Connect
๋ง์ ์ฌ๋๋ค์ด OAuth 2.0๊ณผ OpenID Connect(OIDC)๋ฅผ ํท๊ฐ๋ ค ํด. ๐คท
๊ฐ๋จํ ๋งํ๋ฉด, OAuth 2.0์ ์ธ๊ฐ(Authorization) ํ๋ ์์ํฌ์ด๊ณ , OpenID Connect๋ ์ธ์ฆ(Authentication) ํ๋กํ ์ฝ์ด์ผ.
โข "์ด ์ฑ์ด ๋ด ์ฌ์ง์ ์ ๊ทผํด๋ ๋ผ?" (๊ถํ ๋ถ์ฌ)
โข ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธ
โข ๋ฆฌ์์ค ์ ๊ทผ ๊ถํ ๊ด๋ฆฌ
โข ์ฌ์ฉ์ ์ ์ ํ์ธ์ ๋ถ์ฐจ์
OpenID Connect:
โข "๋ ๋๊ตฌ์ผ?" (์ ์ ํ์ธ)
โข ID ํ ํฐ(JWT) ๋ฐ๊ธ
โข ์ฌ์ฉ์ ์ธ์ฆ ๋ฐ ํ๋กํ ์ ๋ณด ์ ๊ณต
โข OAuth 2.0 ์์ ๊ตฌ์ถ๋ ๋ ์ด์ด
OpenID Connect๋ OAuth 2.0์ ํ์ฅํด์ ๋ง๋ค์ด์ก์ด. ๐๏ธ
OAuth 2.0์ ์ธ๊ฐ ํ๋ฆ์ openid ์ค์ฝํ๋ฅผ ์ถ๊ฐํ๊ณ , ID ํ ํฐ์ด๋ผ๋ ์๋ก์ด ํ ํฐ ํ์
์ ๋์
ํ์ง.
// OpenID Connect ์ธ๊ฐ ์์ฒญ
https://authorization-server.com/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://your-app.com/callback&
scope=openid profile email& // openid ์ค์ฝํ ํ์
state=random_string&
nonce=random_nonce // OIDC ์ถ๊ฐ ํ๋ผ๋ฏธํฐ
// ํ ํฐ ์๋ต (OIDC)
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOk...",
"id_token": "eyJhbGciOiJSUzI1NiIs..." // ID ํ ํฐ ์ถ๊ฐ
}
// ID ํ ํฐ ๋์ฝ๋ฉ ๊ฒฐ๊ณผ
{
"iss": "https://oauth-provider.com",
"sub": "user-12345",
"aud": "your-client-id",
"exp": 1234567890,
"iat": 1234564290,
"nonce": "random_nonce",
"name": "ํ๊ธธ๋",
"email": "hong@example.com",
"picture": "https://example.com/photo.jpg"
}
OpenID Connect ์ฌ์ฉ: ์ฌ์ฉ์ ๋ก๊ทธ์ธ ๋ฐ ์ ์ ํ์ธ์ด ํ์ํ ๊ฒฝ์ฐ (์: "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ")
๋ ๋ค ์ฌ์ฉ: ๋ก๊ทธ์ธ + API ์ ๊ทผ์ด ๋ชจ๋ ํ์ํ ๊ฒฝ์ฐ (๋๋ถ๋ถ์ ํ๋ ์ฑ)
๐ ์ค์ ์๋น์ค์ OAuth 2.0 ๊ตฌํ ์ฌ๋ก
์ด๋ก ๊ณผ ์์ ๋ง์ผ๋ก๋ ๋ถ์กฑํด. ์ค์ ๋ํ ์๋น์ค๋ค์ด ์ด๋ป๊ฒ OAuth 2.0์ ๊ตฌํํ๊ณ ์๋์ง ์ดํด๋ณด์! ๐ข
๊ตฌ๊ธ์ ๊ฐ์ฅ ๋๋ฆฌ ์ฌ์ฉ๋๋ OAuth ์ ๊ณต์ ์ค ํ๋์ผ. ๐
OpenID Connect๋ ์๋ฒฝํ๊ฒ ์ง์ํ๋ฉฐ, ๋งค์ฐ ์์ธํ ๋ฌธ์์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ๊ณตํด.
โข ์ธ๋ถํ๋ ๊ถํ ๊ด๋ฆฌ
โข OAuth 2.0 Playground ์ ๊ณต (ํ ์คํธ ๋๊ตฌ)
โข ์๋ ํ ํฐ ๊ฐฑ์ ์ง์
โข ๊ฐ๋ ฅํ ๋ณด์ ์ ์ฑ (์์ฌ์ค๋ฌ์ด ํ๋ ์๋ ๊ฐ์ง)
๋ณด์ ๊ธฐ๋ฅ:
โข PKCE ํ์ (๋ชจ๋ฐ์ผ ์ฑ)
โข ๋ฆฌ๋ค์ด๋ ํธ URI ์๊ฒฉํ ๊ฒ์ฆ
โข ์ฑ ๊ฒ์ฆ ํ๋ก์ธ์ค (OAuth consent screen)
โข ๋น์ ์ ํ๋ ๋ชจ๋ํฐ๋ง ๋ฐ ์๋ฆผ
๊ฐ๋ฐ์๋ค์๊ฒ ์น์ํ GitHub์ OAuth ๊ตฌํ์ ์ฌํํ๋ฉด์๋ ๊ฐ๋ ฅํด. ๐ป
โข ์ธ๋ถํ๋ ๊ถํ (repo, user, gist ๋ฑ)
โข Personal Access Token ์ง์
โข OAuth Apps์ GitHub Apps ๋ ๊ฐ์ง ๋ฐฉ์ ์ ๊ณต
๋ ํนํ ์ :
โข Device Flow ์ง์ (CLI ๋๊ตฌ์ฉ)
โข Fine-grained personal access tokens (๋ฒ ํ)
โข ์กฐ์ง ์์ค์ OAuth ์ ์ฑ ์ค์ ๊ฐ๋ฅ
ํ๊ตญ์์ ๊ฐ์ฅ ๋ง์ด ์ฌ์ฉ๋๋ ์์ ๋ก๊ทธ์ธ์ด์ง. ๐ฌ
โข ์นด์นด์คํก ๋ฉ์์ง ์ ์ก ๊ถํ
โข ์นด์นด์คํ์ด ์ฐ๋
โข ์น๊ตฌ ๋ชฉ๋ก ์ ๊ทผ (๋์ ํ์)
๋ณด์ ๊ธฐ๋ฅ:
โข ์ฑ ๋ฑ๋ก ์ ์ฌ์ฌ ๊ณผ์
โข ๋น์ฆ๋์ค ์ธ์ฆ ํ์ (ํน์ ๊ถํ)
โข ์ฌ์ฉ์ ๋์ ์ฒ ํ ๊ธฐ๋ฅ
โข ๊ฐ์ธ์ ๋ณด ๋ณดํธ ๊ฐํ (์ต์ ๊ถํ ์์น)
๐งช OAuth 2.0 ๋ณด์ ํ ์คํธ ๋ฐฉ๋ฒ
๊ตฌํ์ด ๋๋ฌ๋ค๊ณ ์์ฌํ๋ฉด ์ ๋ผ. ๐ฌ
์ค์ ๋ก ๋ณด์์ด ์ ๋๋ก ์ ์ฉ๋์๋์ง ํ
์คํธํ๋ ๊ฒ์ด ๋งค์ฐ ์ค์ํด.
โข OAuth 2.0 ์ทจ์ฝ์ ์๋ ํ์ง
โข MITM(Man-in-the-Middle) ํ๋ก์ ๊ธฐ๋ฅ
โข API ํ ์คํธ ์ง์
2. Burp Suite
โข ์ ๋ฌธ๊ฐ์ฉ ์น ๋ณด์ ํ ์คํธ ๋๊ตฌ
โข OAuth 2.0 ํ๋ฌ๊ทธ์ธ ์ ๊ณต
โข ์์ฒญ/์๋ต ์กฐ์ ๋ฐ ๋ถ์
โข ์๋ํ๋ ์ค์บ ๊ธฐ๋ฅ
3. OAuth 2.0 Security Testing Tool
โข OAuth ์ ์ฉ ํ ์คํธ ๋๊ตฌ
โข ๋ค์ํ ๊ณต๊ฒฉ ์๋๋ฆฌ์ค ์๋ฎฌ๋ ์ด์
โข ํ ํฐ ๊ฒ์ฆ ํ ์คํธ
โ ๋ฑ๋ก๋์ง ์์ URI๋ก ๋ณ๊ฒฝ ์๋
โ Path traversal ์๋ (../ ๋ฑ)
โ ์๋ธ๋๋ฉ์ธ ๋ณ๊ฒฝ ์๋
โ ์คํ ๋ฆฌ๋ค์ด๋ ํธ ์ทจ์ฝ์ ํ์ธ
2. State ํ๋ผ๋ฏธํฐ ํ ์คํธ
โ State ์์ด ์์ฒญ
โ ์๋ชป๋ State ๊ฐ ์ฌ์ฉ
โ State ์ฌ์ฌ์ฉ ์๋
โ State ์์ธก ๊ฐ๋ฅ์ฑ ํ์ธ
3. PKCE ํ ์คํธ
โ Code verifier ์์ด ํ ํฐ ๊ตํ ์๋
โ ์๋ชป๋ Code verifier ์ฌ์ฉ
โ Code challenge ์กฐ์ ์๋
4. ํ ํฐ ๋ณด์ ํ ์คํธ
โ ๋ง๋ฃ๋ ํ ํฐ ์ฌ์ฉ ์๋
โ ํ ํฐ ์ฌ์ฌ์ฉ ํ ์คํธ
โ ํ ํฐ ์กฐ์ ์๋ (JWT ์๋ช ๋ณ๊ฒฝ)
โ ํ ํฐ ์ ์ถ ๊ฒฝ๋ก ํ์ธ (๋ก๊ทธ, ํ์คํ ๋ฆฌ ๋ฑ)
5. ์ค์ฝํ ํ ์คํธ
โ ๊ถํ ์๋ ๋ฆฌ์์ค ์ ๊ทผ ์๋
โ ์ค์ฝํ ์์น ๊ณต๊ฒฉ ์๋
โ ๊ณผ๋ํ ๊ถํ ์์ฒญ ํ ์คํธ
6. ์ธ๊ฐ ์ฝ๋ ํ ์คํธ
โ ์ฝ๋ ์ฌ์ฌ์ฉ ์๋
โ ์ฝ๋ ๋ง๋ฃ ์๊ฐ ํ์ธ
โ ๋ค๋ฅธ ํด๋ผ์ด์ธํธ๋ก ์ฝ๋ ์ฌ์ฉ ์๋
// ๋ณด์ ํ
์คํธ ์คํฌ๋ฆฝํธ ์์ (Python)
import requests
import time
# 1. ๋ฆฌ๋ค์ด๋ ํธ URI ์กฐ์ ํ
์คํธ
def test_redirect_uri_manipulation():
malicious_uris = [
'https://attacker.com/steal',
'https://legitimate-app.com/../attacker',
'https://legitimate-app.com?redirect=https://attacker.com',
'http://legitimate-app.com/callback' # HTTP ์๋
]
for uri in malicious_uris:
params = {
'response_type': 'code',
'client_id': 'test-client',
'redirect_uri': uri,
'scope': 'read_profile',
'state': 'random_state'
}
response = requests.get(
'https://auth-server.com/authorize',
params=params,
allow_redirects=False
)
# ๋ฆฌ๋ค์ด๋ ํธ๊ฐ ํ์ฉ๋๋ฉด ์ทจ์ฝ์
if response.status_code == 302:
print(f"โ ๏ธ ์ทจ์ฝ์ ๋ฐ๊ฒฌ: {uri}")
else:
print(f"โ
์์ : {uri}")
# 2. State ํ๋ผ๋ฏธํฐ ํ
์คํธ
def test_state_parameter():
# State ์์ด ์์ฒญ
params_no_state = {
'response_type': 'code',
'client_id': 'test-client',
'redirect_uri': 'https://app.com/callback',
'scope': 'read_profile'
}
response = requests.get(
'https://auth-server.com/authorize',
params=params_no_state
)
if 'error' not in response.url:
print("โ ๏ธ State ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ ์์")
else:
print("โ
State ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ ์ ์")
# 3. ํ ํฐ ๋ง๋ฃ ํ
์คํธ
def test_token_expiration():
# ํ ํฐ ๋ฐ๊ธ
token = get_access_token()
# ์ฆ์ ์ฌ์ฉ (์ ์)
response1 = requests.get(
'https://api.example.com/data',
headers={'Authorization': f'Bearer {token}'}
)
print(f"์ฆ์ ์ฌ์ฉ: {response1.status_code}")
# ๋ง๋ฃ ์๊ฐ ๋๊ธฐ
time.sleep(3700) # 1์๊ฐ ํ
# ๋ง๋ฃ ํ ์ฌ์ฉ ์๋
response2 = requests.get(
'https://api.example.com/data',
headers={'Authorization': f'Bearer {token}'}
)
if response2.status_code == 401:
print("โ
ํ ํฐ ๋ง๋ฃ ์ฒ๋ฆฌ ์ ์")
else:
print("โ ๏ธ ๋ง๋ฃ๋ ํ ํฐ์ด ์ฌ์ ํ ์ ํจํจ")
# 4. ์ค์ฝํ ๊ถํ ํ
์คํธ
def test_scope_enforcement():
# read ๊ถํ๋ง ์๋ ํ ํฐ
read_token = get_token_with_scope('read_profile')
# write ์์
์๋
response = requests.post(
'https://api.example.com/profile',
headers={'Authorization': f'Bearer {read_token}'},
json={'name': 'New Name'}
)
if response.status_code == 403:
print("โ
์ค์ฝํ ๊ฒ์ฆ ์ ์")
else:
print("โ ๏ธ ์ค์ฝํ ๊ฒ์ฆ ์คํจ")
# ํ
์คํธ ์คํ
if __name__ == '__main__':
print("OAuth 2.0 ๋ณด์ ํ
์คํธ ์์...\n")
test_redirect_uri_manipulation()
test_state_parameter()
test_token_expiration()
test_scope_enforcement()
๐ OAuth 2.0 ๋ณด์ ์ฌ๊ณ ์ฌ๋ก ๋ถ์
์ค์ ๋ก ๋ฐ์ํ ๋ณด์ ์ฌ๊ณ ๋ฅผ ํตํด ๋ฐฐ์ฐ๋ ๊ฒ ๊ฐ์ฅ ํจ๊ณผ์ ์ด์ผ. ๐
๋ช ๊ฐ์ง ์ ๋ช
ํ ์ฌ๋ก๋ฅผ ์ดํด๋ณด์.
๊ณต๊ฒฉ ๋ฐฉ๋ฒ:
1. ๊ณต๊ฒฉ์๊ฐ ์ทจ์ฝํ ์๋ธ๋๋ฉ์ธ ๋ฐ๊ฒฌ (์: vulnerable.facebook.com)
2. ํด๋น ์๋ธ๋๋ฉ์ธ์ ๋ฆฌ๋ค์ด๋ ํธ URI๋ก ์ค์
3. ์ฌ์ฉ์๋ฅผ ์ ์ฑ ๋งํฌ๋ก ์ ๋
4. ์ก์ธ์ค ํ ํฐ์ด ๊ณต๊ฒฉ์์ ์๋ฒ๋ก ์ ์ก๋จ
์ํฅ:
์๋ฐฑ๋ง ๋ช ์ ์ฌ์ฉ์ ๊ณ์ ์ด ์ํ์ ๋ ธ์ถ๋ ์ ์์์
๊ตํ:
โข ๋ฆฌ๋ค์ด๋ ํธ URI๋ ์ ํํ ๋งค์นญํด์ผ ํจ (์์ผ๋์นด๋ ๊ธ์ง)
โข ์๋ธ๋๋ฉ์ธ๋ ๋ณ๋๋ก ๊ฒ์ฆ ํ์
โข ์ ๊ธฐ์ ์ธ ๋ณด์ ๊ฐ์ฌ ํ์
๊ณต๊ฒฉ ๋ฐฉ๋ฒ:
1. ์ ์ ์ฑ์ด myapp://callback ์คํด ์ฌ์ฉ
2. ์ ์ฑ ์ฑ๋ ๋์ผํ ์คํด ๋ฑ๋ก
3. ์ฌ์ฉ์๊ฐ OAuth ์ธ์ฆ ์๋ฃ
4. OS๊ฐ ์ ์ฑ ์ฑ์ผ๋ก ์ธ๊ฐ ์ฝ๋ ์ ๋ฌ (๋จผ์ ์ค์น๋ ์ฑ ๋๋ ๋๋ค)
5. ์ ์ฑ ์ฑ์ด ์ธ๊ฐ ์ฝ๋๋ก ํ ํฐ ๋ฐ๊ธ๋ฐ์
์ํฅ:
์์ญ ๊ฐ์ ์ธ๊ธฐ ์ฑ์ด ์ํฅ๋ฐ์
๊ตํ:
โข PKCE ํ์ ์ฌ์ฉ (์ด ๊ณต๊ฒฉ ์๋ฒฝํ ๋ฐฉ์ด)
โข App Links (Android) / Universal Links (iOS) ์ฌ์ฉ
โข ์ปค์คํ URL ์คํด ์ฌ์ฉ ์์
์์ธ:
โข ๋๋ฒ๊น ๋ชฉ์ ์ผ๋ก ์ถ๊ฐํ ๋ก๊น ์ฝ๋๋ฅผ ํ๋ก๋์ ์์ ์ ๊ฑฐํ์ง ์์
โข ๋ก๊ทธ ํํฐ๋ง ๋ฏธํก
โข ๋ด๋ถ ์ ๊ทผ ํต์ ๋ถ์กฑ
์ํฅ:
์๋ฐฑ๋ง ๋ช ์ ๊ณ์ ์ด ์ ์ฌ์ ์ผ๋ก ์ํ์ ๋ ธ์ถ
๊ตํ:
โข ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ๋ก๊ทธ์ ๊ธฐ๋กํ์ง ๋ง ๊ฒ
โข ํ๋ก๋์ ๋ฐฐํฌ ์ ์ฒ ์ ํ ์ฝ๋ ๋ฆฌ๋ทฐ
โข ๋ก๊ทธ ์ ๊ทผ ๊ถํ ์ต์ํ
โข ์๋ํ๋ ๋ฏผ๊ฐ์ ๋ณด ํ์ง ๋๊ตฌ ์ฌ์ฉ
2. ์ต์ ๋ณด์ ํ์ค ์ ์ฉ: PKCE, HTTPS ๋ฑ์ ํ์
3. ์ต์ ๊ถํ ์์น: ํ์ํ ์ต์ํ์ ๊ถํ๋ง ์์ฒญ
4. ๋ค์ธต ๋ฐฉ์ด: ํ๋์ ๋ณด์ ๋ฉ์ปค๋์ฆ์๋ง ์์กดํ์ง ๋ง ๊ฒ
5. ์ ๊ธฐ์ ์ธ ๋ณด์ ๊ฐ์ฌ: ์ธ๋ถ ์ ๋ฌธ๊ฐ์ ๊ฒํ ํ์
๐ OAuth 2.0์ ๋ฏธ๋: OAuth 2.1
OAuth 2.0์ด ์๋ฒฝํ ๊ฑด ์๋์ผ. ๊ทธ๋์ OAuth 2.1์ด ๊ฐ๋ฐ ์ค์ด์ผ! ๐ฎ
2020๋
๋ถํฐ ์์
์ด ์์๋์๊ณ , ํ์ฌ ๋๋ํํธ ๋จ๊ณ์ ์์ด.
โข ๋ชจ๋ ํด๋ผ์ด์ธํธ์ PKCE ์ ์ฉ (Public, Confidential ๋ชจ๋)
โข Implicit Grant ๊ณต์ ํ๊ธฐ
2. ๋ฆฌ๋ค์ด๋ ํธ URI ์ ํํ ๋งค์นญ
โข ์์ผ๋์นด๋ ๊ธ์ง
โข ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๊น์ง ์ ํํ ์ผ์นํด์ผ ํจ
3. ๋ฆฌํ๋ ์ ํ ํฐ ๋ณด์ ๊ฐํ
โข ๋ฆฌํ๋ ์ ํ ํฐ ํ์ ๊ถ์ฅ
โข ์ผํ์ฉ ๋ฆฌํ๋ ์ ํ ํฐ
4. ๋ณด์ ๋ชจ๋ฒ ์ฌ๋ก ํตํฉ
โข RFC 8252 (๋ชจ๋ฐ์ผ ์ฑ OAuth)
โข RFC 8628 (Device Flow)
โข RFC 8707 (Resource Indicators)
5. ๋จ์ํ
โข ๋ถํ์ํ ์ต์ ์ ๊ฑฐ
โข ๋ช ํํ ๊ฐ์ด๋๋ผ์ธ ์ ๊ณต
OAuth 2.1์ ๊ธฐ์กด OAuth 2.0์ ๋ณด์ ์ทจ์ฝ์ ์ ํด๊ฒฐํ๊ณ ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ํ์คํํ๋ ๊ฒ ๋ชฉํ์ผ. ๐ฏ
ํ์ ํธํ์ฑ์ ์ ์งํ๋ฉด์๋ ๋ ์์ ํ ๊ตฌํ์ ๊ฐ์ ํ๋ ๊ฑฐ์ง.
๐ผ ์ค๋ฌด์์์ OAuth 2.0 ๊ตฌํ ํ
์ด๋ก ๊ณผ ์ค์ ์ ๋ค๋ฅด์ง. ์ค์ ํ๋ก์ ํธ์์ OAuth 2.0์ ๊ตฌํํ ๋ ์ ์ฉํ ํ๋ค์ ๊ณต์ ํ ๊ฒ! ๐ก
์ฒ์๋ถํฐ ์ง์ ๊ตฌํํ์ง ๋ง๊ณ , ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ ์ข์. ๐
โข Passport.js (๋ค์ํ OAuth ์ ๊ณต์ ์ง์)
โข simple-oauth2 (๊ฐ๋จํ ๊ตฌํ)
โข openid-client (OpenID Connect ์ง์)
Python:
โข Authlib (ํฌ๊ด์ ์ธ OAuth/OIDC ๋ผ์ด๋ธ๋ฌ๋ฆฌ)
โข requests-oauthlib (๊ฐ๋จํ ํด๋ผ์ด์ธํธ)
โข python-social-auth (Django/Flask ํตํฉ)
Java:
โข Spring Security OAuth
โข Nimbus OAuth 2.0 SDK
โข Pac4j
JavaScript (ํ๋ก ํธ์๋):
โข oidc-client-js (SPA์ฉ)
โข hello.js (๋ค์ค ์ ๊ณต์ ์ง์)
โข Auth0 SDK
// ํ๊ฒฝ ๋ณ์๋ก OAuth ์ค์ ๊ด๋ฆฌ (.env ํ์ผ)
# ๊ฐ๋ฐ ํ๊ฒฝ
OAUTH_CLIENT_ID=dev-client-id
OAUTH_CLIENT_SECRET=dev-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/callback
OAUTH_AUTHORIZATION_URL=https://oauth-provider.com/authorize
OAUTH_TOKEN_URL=https://oauth-provider.com/token
# ํ๋ก๋์
ํ๊ฒฝ
OAUTH_CLIENT_ID=prod-client-id
OAUTH_CLIENT_SECRET=prod-client-secret
OAUTH_REDIRECT_URI=https://myapp.com/callback
OAUTH_AUTHORIZATION_URL=https://oauth-provider.com/authorize
OAUTH_TOKEN_URL=https://oauth-provider.com/token
// ์ค์ ๋ก๋ (Node.js)
require('dotenv').config();
const oauthConfig = {
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
redirectUri: process.env.OAUTH_REDIRECT_URI,
authorizationUrl: process.env.OAUTH_AUTHORIZATION_URL,
tokenUrl: process.env.OAUTH_TOKEN_URL
};
// ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ์ฝ๋์ ํ๋์ฝ๋ฉํ์ง ๋ง ๊ฒ!
OAuth ์ธ์ฆ ๊ณผ์ ์์ ๋ค์ํ ์๋ฌ๊ฐ ๋ฐ์ํ ์ ์์ด. ์ ์ ํ ์๋ฌ ์ฒ๋ฆฌ๊ฐ ์ค์ํด. โ ๏ธ
// ํฌ๊ด์ ์ธ ์๋ฌ ์ฒ๋ฆฌ ์์
app.get('/callback', async (req, res) => {
try {
// OAuth ์๋ฌ ํ์ธ
if (req.query.error) {
const errorType = req.query.error;
const errorDescription = req.query.error_description;
// ์๋ฌ ํ์
๋ณ ์ฒ๋ฆฌ
switch(errorType) {
case 'access_denied':
// ์ฌ์ฉ์๊ฐ ๊ถํ ๋ถ์ฌ ๊ฑฐ๋ถ
return res.redirect('/login?error=access_denied');
case 'invalid_request':
// ์๋ชป๋ ์์ฒญ ํ๋ผ๋ฏธํฐ
console.error('Invalid OAuth request:', errorDescription);
return res.status(400).send('Invalid request');
case 'unauthorized_client':
// ํด๋ผ์ด์ธํธ ์ธ์ฆ ์คํจ
console.error('Client authentication failed');
return res.status(401).send('Unauthorized');
case 'server_error':
// OAuth ์๋ฒ ์๋ฌ
console.error('OAuth server error:', errorDescription);
return res.status(503).send('Service temporarily unavailable');
default:
console.error('Unknown OAuth error:', errorType, errorDescription);
return res.status(500).send('Authentication failed');
}
}
// State ๊ฒ์ฆ
if (!req.query.state || req.query.state !== req.session.oauthState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// ์ธ๊ฐ ์ฝ๋ ํ์ธ
if (!req.query.code) {
throw new Error('Authorization code not received');
}
// ํ ํฐ ๊ตํ
const tokens = await exchangeCodeForToken(req.query.code);
// ์ฌ์ฉ์ ์ ๋ณด ์กฐํ
const userInfo = await getUserInfo(tokens.access_token);
// ์ธ์
์ ์ฅ
req.session.user = userInfo;
req.session.accessToken = tokens.access_token;
res.redirect('/dashboard');
} catch (error) {
// ์์์น ๋ชปํ ์๋ฌ
console.error('OAuth callback error:', error);
// ์ฌ์ฉ์์๊ฒ ์น์ ํ ์๋ฌ ๋ฉ์์ง
res.status(500).render('error', {
message: '๋ก๊ทธ์ธ ์ค ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค. ๋ค์ ์๋ํด์ฃผ์ธ์.',
details: process.env.NODE_ENV === 'development' ? error.message : null
});
}
});
// ํ ํฐ ๊ตํ ํจ์ (์ฌ์๋ ๋ก์ง ํฌํจ)
async function exchangeCodeForToken(code, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await axios.post(tokenUrl, {
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret
}, {
timeout: 5000 // 5์ด ํ์์์
});
return response.data;
} catch (error) {
if (i === retries - 1) {
// ๋ง์ง๋ง ์๋ ์คํจ
throw error;
}
// ๋คํธ์ํฌ ์๋ฌ์ธ ๊ฒฝ์ฐ ์ฌ์๋
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
console.log(`Token exchange retry ${i + 1}/${retries}`);
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
continue;
}
// ๋ค๋ฅธ ์๋ฌ๋ ์ฆ์ throw
throw error;
}
}
}
OAuth ์ธ์ฆ ๊ณผ์ ์ ์ ์ ํ ๋ก๊น ํ๊ณ ๋ชจ๋ํฐ๋งํ๋ ๊ฒ ์ค์ํด. ํ์ง๋ง ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ ๋ ๋ก๊ทธ์ ๋จ๊ธฐ๋ฉด ์ ๋ผ! ๐
// ์์ ํ ๋ก๊น
์์
const winston = require('winston');
// ๋ฏผ๊ฐํ ์ ๋ณด ํํฐ๋ง
const sensitiveFields = ['access_token', 'refresh_token', 'client_secret', 'code_verifier'];
function sanitizeLog(obj) {
const sanitized = { ...obj };
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
}
return sanitized;
}
// ๋ก๊ฑฐ ์ค์
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'oauth-error.log', level: 'error' }),
new winston.transports.File({ filename: 'oauth-combined.log' })
]
});
// OAuth ์ด๋ฒคํธ ๋ก๊น
function logOAuthEvent(event, data) {
logger.info({
event: event,
timestamp: new Date().toISOString(),
data: sanitizeLog(data)
});
}
// ์ฌ์ฉ ์์
app.get('/login', (req, res) => {
const state = generateState();
const codeVerifier = generateCodeVerifier();
// ๋ก๊ทธ ๊ธฐ๋ก (๋ฏผ๊ฐํ ์ ๋ณด ์ ์ธ)
logOAuthEvent('oauth_login_initiated', {
userId: req.session.userId,
clientId: OAUTH_CONFIG.clientId,
scope: OAUTH_CONFIG.scope,
state: state.substring(0, 8) + '...' // ์ผ๋ถ๋ง ๊ธฐ๋ก
});
// ... OAuth ๋ก์ง
});
// ์ด์ ํ์ ํ์ง
function detectAnomalousActivity(userId, event) {
// ์งง์ ์๊ฐ ๋ด ๊ณผ๋ํ ์์ฒญ
const recentAttempts = getRecentLoginAttempts(userId, 300); // 5๋ถ
if (recentAttempts > 10) {
logger.warn({
event: 'suspicious_activity',
userId: userId,
attempts: recentAttempts,
message: 'Excessive login attempts detected'
});
// ์๋ฆผ ์ ์ก ๋๋ ๊ณ์ ์ผ์ ์ ๊ธ
sendSecurityAlert(userId);
return true;
}
return false;
}
๐ OAuth 2.0 ํ์ต ๋ฆฌ์์ค
๋ ๊น์ด ๊ณต๋ถํ๊ณ ์ถ๋ค๋ฉด ์ด ๋ฆฌ์์ค๋ค์ ์ถ์ฒํด! ๐
โข RFC 6750: OAuth 2.0 Bearer Token Usage
โข RFC 7636: PKCE (Proof Key for Code Exchange)
โข RFC 8252: OAuth 2.0 for Native Apps
โข OAuth 2.0 Security Best Current Practice
๐ ์จ๋ผ์ธ ๋ฆฌ์์ค
โข OAuth.net: ๊ณต์ OAuth ์ปค๋ฎค๋ํฐ ์ฌ์ดํธ
โข Auth0 Blog: ์ค์ฉ์ ์ธ OAuth ๊ฐ์ด๋
โข OWASP OAuth 2.0 Cheat Sheet
โข Google OAuth 2.0 Playground: ์ค์ต ๋๊ตฌ
๐บ ๋์์ ๊ฐ์
โข "OAuth 2.0 and OpenID Connect (in plain English)" by Nate Barbettini
โข Udemy, Coursera์ OAuth ๊ด๋ จ ๊ฐ์
๐ ์ถ์ฒ ๋์
โข "OAuth 2 in Action" by Justin Richer and Antonio Sanso
โข "Identity and Data Security for Web Development" by Jonathan LeBlanc and Tim Messerschmidt
๐ฏ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์, ์ฌ๊ธฐ๊น์ง ์ฝ์๋ค๋ฉด ์ ๋ง ๋๋จํด! ๐
OAuth 2.0์ ํ๋ ์น ์ ํ๋ฆฌ์ผ์ด์
์ ํ์ ์์์ผ. ์ฌ์ฉ์ ๊ฒฝํ์ ํฅ์์ํค๋ฉด์๋ ๋ณด์์ ์ ์งํ ์ ์๋ ๊ฐ๋ ฅํ ๋๊ตฌ์ง.
โข ์ธ๊ฐ(Authorization) ํ๋ ์์ํฌ
โข ์ฌ์ฉ์ ๋น๋ฐ๋ฒํธ๋ฅผ ๊ณต์ ํ์ง ์๊ณ ๋ฆฌ์์ค ์ ๊ทผ ๊ถํ ๋ถ์ฌ
โข 4๊ฐ์ง ๊ถํ ๋ถ์ฌ ๋ฐฉ์ ์ ๊ณต
2. ์ฃผ์ ๋ณด์ ์ํ
โข ์ธ๊ฐ ์ฝ๋ ๊ฐ๋ก์ฑ๊ธฐ
โข ๋ฆฌ๋ค์ด๋ ํธ URI ์กฐ์
โข ํ ํฐ ์ ์ถ
โข CSRF ๊ณต๊ฒฉ
โข ํด๋ผ์ด์ธํธ ์์ฅ
3. ํ์ ๋ณด์ ๋์ฑ
โข PKCE ์ฌ์ฉ (๋ชจ๋ ํด๋ผ์ด์ธํธ)
โข State ํ๋ผ๋ฏธํฐ ๊ฒ์ฆ
โข HTTPS ํ์
โข ๋ฆฌ๋ค์ด๋ ํธ URI ์ ํํ ๋งค์นญ
โข ์งง์ ํ ํฐ ์ ํจ๊ธฐ๊ฐ
โข ์ต์ ๊ถํ ์์น
4. ์ค๋ฌด ํ
โข ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
โข ์ ์ ํ ์๋ฌ ์ฒ๋ฆฌ
โข ์์ ํ ๋ก๊น
โข ์ ๊ธฐ์ ์ธ ๋ณด์ ํ ์คํธ
โข ์ต์ ๋ณด์ ํ์ค ์ ์ฉ
OAuth 2.0์ ๋ณต์กํด ๋ณด์ด์ง๋ง, ๊ธฐ๋ณธ ์๋ฆฌ๋ฅผ ์ดํดํ๊ณ ๋ณด์ ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ๋ฐ๋ฅด๋ฉด ์์ ํ๊ฒ ๊ตฌํํ ์ ์์ด. ๐
๋ณด์์ ํ ๋ฒ์ ์์ฑ๋๋ ๊ฒ ์๋๋ผ ์ง์์ ์ธ ๊ฐ์ ๊ณผ์ ์ด์ผ. ์๋ก์ด ์ํ์ด ๋ฐ๊ฒฌ๋๋ฉด ๋์ ๋ฐฉ๋ฒ๋ ์งํํ์ง. ํญ์ ์ต์ ๋ณด์ ๋ํฅ์ ์ฃผ์ํ๊ณ , ์ ๊ธฐ์ ์ผ๋ก ์์คํ
์ ์ ๊ฒํ๋ ๊ฒ ์ค์ํด.
์ด ๊ธ์ด OAuth 2.0์ ์ดํดํ๊ณ ์์ ํ๊ฒ ๊ตฌํํ๋ ๋ฐ ๋์์ด ๋์๊ธธ ๋ฐ๋ผ! ๐
๊ถ๊ธํ ์ ์ด ์๋ค๋ฉด ์ธ์ ๋ ์ง ๊ณต์ ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ๊ฑฐ๋ ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํด๋ด. ํจ๊ป ๋ฐฐ์ฐ๊ณ ์ฑ์ฅํ๋ ๊ฒ ๊ฐ๋ฐ์์ ๋งค๋ ฅ์ด๋๊น! ๐ช
๐ Happy Coding! ๐
์์ ํ๊ณ ๋ฉ์ง ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ง๋ค์ด๋ณด์!
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.