Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df5af95cb3 | ||
|
|
6c310c93b7 | ||
|
|
e856e7af4d | ||
|
|
bcb563a04e | ||
|
|
aa2c3907f5 | ||
|
|
f45a2a2aaa | ||
|
|
7dc0a3e5da | ||
|
|
b16e1dccf7 | ||
|
|
711878379c | ||
|
|
f2595f748f | ||
|
|
e62bbb4df0 | ||
|
|
8f6d67df35 |
@@ -9,3 +9,135 @@
|
||||
[ 20184ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 32473ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 50697ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 65961ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 85590ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 99644ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 115524ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 130773ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 134354ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 142549ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 151361ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 155246ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 165901ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 169379ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 180952ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 190372ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 193657ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 205737ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 220582ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 225496ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 240339ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 244029ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 264211ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 273424ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 284893ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 292571ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 305778ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 306804ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 324378ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 335169ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 337968ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 352967ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 364258ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 379409ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 399590ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 403681ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 406340ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 421033ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 428667ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 429483ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 437573ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 442286ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 445865ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 456312ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 464043ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 480382ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 491438ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 494608ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 508231ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 528305ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 529632ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 538033ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 550079ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 566347ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 586157ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 603877ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 615779ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 624657ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 629879ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 637255ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 643298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 650047ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 668080ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 669816ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 675422ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 677001ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 686611ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 692655ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 713114ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 723787ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 730443ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 751427ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 757583ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 776007ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 792298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 803509ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 822917ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 827374ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 847278ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 867460ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 875338ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 886296ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 900938ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 912511ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 917067ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 924405ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 930435ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 942211ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 953146ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 957153ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 969140ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 984088ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1003544ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1014908ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1020851ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1034668ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1040406ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1056887ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1062828ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1073375ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1085052ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1102031ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1110039ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1129341ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1132300ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1133670ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1142705ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1160906ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1172400ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1190482ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1196673ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1213666ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1225136ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1228310ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1242751ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1243874ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1256126ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1274444ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1288526ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1307058ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1320986ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1325134ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1335974ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1344832ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1354780ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1364165ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1383346ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1399019ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1400207ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1413875ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1423809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1427502ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1430773ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1436408ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
[ 1447464ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[ 207ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://app1.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 236ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://app1.hyeonworks.com/bff/api/me:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 7292ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/bff/token-boundary:0
|
||||
[ 7293ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 6726ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/:0
|
||||
[ 6726ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 6898ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
|
||||
[ 23950ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 275ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=G2-BDWkehNWO7hGwhYCxXBVRKZ6AomLIrSLBtIXr0Gw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=4155f58e-6a58-e47b-93bd-7e2b625c2b91.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
[ 408ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=Da-7OcMB4f7vyHgr-6CqrTtJpmj1R_SfRfcE3CUJNzE%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=96d66247-91b0-0cc1-89dc-e527c2b69bf2.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 266ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=TZnQvjIWCEySrf4PMg2WLVFoOfkyOJWB58f2LGjVfpo%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=8ae913a1-2647-0ed9-625e-3d80e1565024.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
[ 416ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=uAYwZp59ncz95XjkJXAlIgsT7oksBQBViiQS07t2eow%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=7b7fc816-0b27-93a0-83b8-656488813253.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
@@ -0,0 +1,2 @@
|
||||
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=0EOFj1PoLyPil0dgukpi7zKW4JKnGZTP9Wj6EhTR-lw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=d13cc206-133f-00a9-9908-599988c4d7cf.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
[ 457ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=kbYC5O4_ELsoQFw85vyYfgWEqlI2yWImB4rmelbmSTM%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=91f9fde9-f376-b6f3-4622-a1ba674cc4fd.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 307ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 261ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 882ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 178ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f21e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f21e3]
|
||||
- paragraph [ref=f21e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f21e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f21e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f21e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f21e8] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f22e3]:
|
||||
- banner [ref=f22e4]:
|
||||
- generic [ref=f22e5]: keycloak-patterns
|
||||
- main [ref=f22e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f22e8]
|
||||
- generic [ref=f22e12]:
|
||||
- generic [ref=f22e13]:
|
||||
- generic [ref=f22e14]: Username or email
|
||||
- textbox "Username or email" [active] [ref=f22e17]
|
||||
- generic [ref=f22e18]:
|
||||
- generic [ref=f22e19]: Password
|
||||
- generic [ref=f22e21]:
|
||||
- textbox "Password" [ref=f22e24]
|
||||
- button "Show password" [ref=f22e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f22e27]:
|
||||
- button "Sign In" [ref=f22e30] [cursor=pointer]
|
||||
@@ -0,0 +1,20 @@
|
||||
- generic [ref=f23e3]:
|
||||
- banner [ref=f23e4]:
|
||||
- generic [ref=f23e5]: keycloak-patterns
|
||||
- main [ref=f23e6]:
|
||||
- heading "Update Account Information" [level=1] [ref=f23e8]
|
||||
- generic [ref=f23e9]:
|
||||
- generic [ref=f23e10]: "* Required fields"
|
||||
- generic [ref=f23e13]:
|
||||
- generic [ref=f23e14]:
|
||||
- generic [ref=f23e15]: Email *
|
||||
- textbox "Email" [ref=f23e19]: labuser@example.com
|
||||
- generic [ref=f23e20]:
|
||||
- generic [ref=f23e21]: First name *
|
||||
- textbox "First name" [invalid] [ref=f23e25]
|
||||
- generic [ref=f23e26]: Please specify this field.
|
||||
- generic [ref=f23e31]:
|
||||
- generic [ref=f23e32]: Last name *
|
||||
- textbox "Last name" [invalid] [ref=f23e36]
|
||||
- generic [ref=f23e37]: Please specify this field.
|
||||
- button "Submit" [ref=f23e44]
|
||||
@@ -0,0 +1,20 @@
|
||||
- generic [ref=f23e3]:
|
||||
- banner [ref=f23e4]:
|
||||
- generic [ref=f23e5]: keycloak-patterns
|
||||
- main [ref=f23e6]:
|
||||
- heading "Update Account Information" [level=1] [ref=f23e8]
|
||||
- generic [ref=f23e9]:
|
||||
- generic [ref=f23e10]: "* Required fields"
|
||||
- generic [ref=f23e13]:
|
||||
- generic [ref=f23e14]:
|
||||
- generic [ref=f23e15]: Email *
|
||||
- textbox "Email" [ref=f23e19]: labuser@example.com
|
||||
- generic [ref=f23e20]:
|
||||
- generic [ref=f23e21]: First name *
|
||||
- textbox "First name" [invalid] [ref=f23e25]: Lab
|
||||
- generic [ref=f23e26]: Please specify this field.
|
||||
- generic [ref=f23e31]:
|
||||
- generic [ref=f23e32]: Last name *
|
||||
- textbox "Last name" [active] [invalid] [ref=f23e36]: User
|
||||
- generic [ref=f23e37]: Please specify this field.
|
||||
- button "Submit" [ref=f23e44]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f24e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f24e3]
|
||||
- paragraph [ref=f24e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f24e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f24e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f24e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f24e8] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f25e3]:
|
||||
- banner [ref=f25e4]:
|
||||
- generic [ref=f25e5]: keycloak-patterns
|
||||
- main [ref=f25e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f25e8]
|
||||
- generic [ref=f25e12]:
|
||||
- generic [ref=f25e13]:
|
||||
- generic [ref=f25e14]: Username or email
|
||||
- textbox "Username or email" [active] [ref=f25e17]
|
||||
- generic [ref=f25e18]:
|
||||
- generic [ref=f25e19]: Password
|
||||
- generic [ref=f25e21]:
|
||||
- textbox "Password" [ref=f25e24]
|
||||
- button "Show password" [ref=f25e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f25e27]:
|
||||
- button "Sign In" [ref=f25e30] [cursor=pointer]
|
||||
@@ -0,0 +1,9 @@
|
||||
- generic [ref=f26e2]:
|
||||
- heading "Login with OAuth 2.0" [level=2] [ref=f26e3]
|
||||
- alert [ref=f26e4]: Invalid credentials
|
||||
- table [ref=f26e5]:
|
||||
- rowgroup [ref=f26e6]:
|
||||
- row [ref=f26e7]:
|
||||
- cell [ref=f26e8]:
|
||||
- link "keycloak" [ref=f26e9] [cursor=pointer]:
|
||||
- /url: /oauth2/authorization/keycloak
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f27e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
|
||||
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f27e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
|
||||
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f27e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3]
|
||||
- paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f27e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f27e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f28e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f29e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f29e3]
|
||||
- paragraph [ref=f29e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f29e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f29e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f29e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f29e8] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f30e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,5 @@
|
||||
- generic [active] [ref=f31e1]:
|
||||
- heading "Whitelabel Error Page" [level=1] [ref=f31e2]
|
||||
- paragraph [ref=f31e3]: This application has no explicit mapping for /error, so you are seeing this as a fallback.
|
||||
- generic [ref=f31e4]: Fri Sep 04 05:00:51 GMT 2026
|
||||
- generic [ref=f31e5]: There was an unexpected error (type=Internal Server Error, status=500).
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f32e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f33e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f33e3]
|
||||
- paragraph [ref=f33e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f33e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f33e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f33e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f33e8] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f34e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f35e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f36e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f36e3]
|
||||
- paragraph [ref=f36e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f36e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f36e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f36e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f36e8] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f37e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f38e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f38e3]
|
||||
- paragraph [ref=f38e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f38e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f38e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f38e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f38e8] [cursor=pointer]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f39e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f39e3]
|
||||
- paragraph [ref=f39e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f39e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f39e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f39e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f39e8] [cursor=pointer]
|
||||
@@ -0,0 +1,4 @@
|
||||
- generic [active] [ref=f40e1]:
|
||||
- heading "502 Bad Gateway" [level=1] [ref=f40e3]
|
||||
- separator [ref=f40e4]
|
||||
- generic [ref=f40e5]: nginx/1.30.4
|
||||
@@ -0,0 +1,4 @@
|
||||
- generic [active] [ref=f41e1]:
|
||||
- heading "502 Bad Gateway" [level=1] [ref=f41e3]
|
||||
- separator [ref=f41e4]
|
||||
- generic [ref=f41e5]: nginx/1.30.4
|
||||
@@ -0,0 +1,4 @@
|
||||
- generic [active] [ref=f42e1]:
|
||||
- heading "502 Bad Gateway" [level=1] [ref=f42e3]
|
||||
- separator [ref=f42e4]
|
||||
- generic [ref=f42e5]: nginx/1.30.4
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f43e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpOaFpUSXhPREpsTWpnM01EQXhZakF5WVEuTk81VE82RHRod2NWZWstaHpPZVg1Zw==|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f44e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVOemhrWm1GbFptSmtZV1JqWTJJNU5tTTNZbVV4TmpJMVpHSmhOVFl4TmcucmoxSnJPYjJKOW1ZV191aXVWa2FCZw==|1788500538|RoiStOeQcIDldxB3cckyO-OAiMgBjBfw5gOSvUsgTFU=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.1.132\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f45e3]:
|
||||
- banner [ref=f45e4]:
|
||||
- generic [ref=f45e5]: keycloak-patterns
|
||||
- main [ref=f45e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f45e8]
|
||||
- generic [ref=f45e12]:
|
||||
- generic [ref=f45e13]:
|
||||
- generic [ref=f45e14]: Username or email
|
||||
- textbox "Username or email" [active] [ref=f45e17]
|
||||
- generic [ref=f45e18]:
|
||||
- generic [ref=f45e19]: Password
|
||||
- generic [ref=f45e21]:
|
||||
- textbox "Password" [ref=f45e24]
|
||||
- button "Show password" [ref=f45e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f45e27]:
|
||||
- button "Sign In" [ref=f45e30] [cursor=pointer]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f46e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f46e3]
|
||||
- paragraph [ref=f46e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f46e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f46e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f46e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f46e8] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f47e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f48e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f49e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f50e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}"
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f51e3]:
|
||||
- banner [ref=f51e4]:
|
||||
- generic [ref=f51e5]: keycloak-patterns
|
||||
- main [ref=f51e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f51e8]
|
||||
- generic [ref=f51e12]:
|
||||
- generic [ref=f51e13]:
|
||||
- generic [ref=f51e14]: Username or email
|
||||
- textbox "Username or email" [active] [ref=f51e17]
|
||||
- generic [ref=f51e18]:
|
||||
- generic [ref=f51e19]: Password
|
||||
- generic [ref=f51e21]:
|
||||
- textbox "Password" [ref=f51e24]
|
||||
- button "Show password" [ref=f51e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f51e27]:
|
||||
- button "Sign In" [ref=f51e30] [cursor=pointer]
|
||||
@@ -0,0 +1,7 @@
|
||||
- main [ref=f52e2]:
|
||||
- heading "AP3 · Backend-for-Frontend" [level=1] [ref=f52e3]
|
||||
- paragraph [ref=f52e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다.
|
||||
- button "Keycloak 로그인" [ref=f52e5] [cursor=pointer]
|
||||
- button "token 경계 확인" [ref=f52e6] [cursor=pointer]
|
||||
- button "BFF 경유 API 호출" [ref=f52e7] [cursor=pointer]
|
||||
- button "CSRF token으로 상태 변경" [ref=f52e8] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f53e3]:
|
||||
- banner [ref=f53e4]:
|
||||
- generic [ref=f53e5]: keycloak-patterns
|
||||
- main [ref=f53e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f53e8]
|
||||
- generic [ref=f53e12]:
|
||||
- generic [ref=f53e13]:
|
||||
- generic [ref=f53e14]: Username or email
|
||||
- textbox "Username or email" [ref=f53e17]
|
||||
- generic [ref=f53e18]:
|
||||
- generic [ref=f53e19]: Password
|
||||
- generic [ref=f53e21]:
|
||||
- textbox "Password" [ref=f53e24]
|
||||
- button "Show password" [ref=f53e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f53e27]:
|
||||
- button "Sign In" [ref=f53e30] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f53e3]:
|
||||
- banner [ref=f53e4]:
|
||||
- generic [ref=f53e5]: keycloak-patterns
|
||||
- main [ref=f53e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f53e8]
|
||||
- generic [ref=f53e12]:
|
||||
- generic [ref=f53e13]:
|
||||
- generic [ref=f53e14]: Username or email
|
||||
- textbox "Username or email" [ref=f53e17]: labuser
|
||||
- generic [ref=f53e18]:
|
||||
- generic [ref=f53e19]: Password
|
||||
- generic [ref=f53e21]:
|
||||
- textbox "Password" [active] [ref=f53e24]: labpass
|
||||
- button "Show password" [ref=f53e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f53e27]:
|
||||
- button "Sign In" [ref=f53e30] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f53e3]:
|
||||
- banner [ref=f53e4]:
|
||||
- generic [ref=f53e5]: keycloak-patterns
|
||||
- main [ref=f53e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f53e8]
|
||||
- generic [ref=f53e12]:
|
||||
- generic [ref=f53e13]:
|
||||
- generic [ref=f53e14]: Username or email
|
||||
- textbox "Username or email" [ref=f53e17]: labuser
|
||||
- generic [ref=f53e18]:
|
||||
- generic [ref=f53e19]: Password
|
||||
- generic [ref=f53e21]:
|
||||
- textbox "Password" [active] [ref=f53e24]: labpass
|
||||
- button "Show password" [ref=f53e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f53e27]:
|
||||
- button "Sign In" [ref=f53e30] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f53e3]:
|
||||
- banner [ref=f53e4]:
|
||||
- generic [ref=f53e5]: keycloak-patterns
|
||||
- main [ref=f53e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f53e8]
|
||||
- generic [ref=f53e12]:
|
||||
- generic [ref=f53e13]:
|
||||
- generic [ref=f53e14]: Username or email
|
||||
- textbox "Username or email" [ref=f53e17]: labuser
|
||||
- generic [ref=f53e18]:
|
||||
- generic [ref=f53e19]: Password
|
||||
- generic [ref=f53e21]:
|
||||
- textbox "Password" [active] [ref=f53e24]: labpass
|
||||
- button "Show password" [ref=f53e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f53e27]:
|
||||
- button "Sign In" [ref=f53e30] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f54e3]:
|
||||
- banner [ref=f54e4]:
|
||||
- generic [ref=f54e5]: keycloak-patterns
|
||||
- main [ref=f54e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f54e8]
|
||||
- generic [ref=f54e12]:
|
||||
- generic [ref=f54e13]:
|
||||
- generic [ref=f54e14]: Username or email
|
||||
- textbox "Username or email" [ref=f54e17]
|
||||
- generic [ref=f54e18]:
|
||||
- generic [ref=f54e19]: Password
|
||||
- generic [ref=f54e21]:
|
||||
- textbox "Password" [ref=f54e24]
|
||||
- button "Show password" [ref=f54e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f54e27]:
|
||||
- button "Sign In" [ref=f54e30] [cursor=pointer]
|
||||
@@ -0,0 +1,16 @@
|
||||
- generic [ref=f54e3]:
|
||||
- banner [ref=f54e4]:
|
||||
- generic [ref=f54e5]: keycloak-patterns
|
||||
- main [ref=f54e6]:
|
||||
- heading "Sign in to your account" [level=1] [ref=f54e8]
|
||||
- generic [ref=f54e12]:
|
||||
- generic [ref=f54e13]:
|
||||
- generic [ref=f54e14]: Username or email
|
||||
- textbox "Username or email" [ref=f54e17]: labuser
|
||||
- generic [ref=f54e18]:
|
||||
- generic [ref=f54e19]: Password
|
||||
- generic [ref=f54e21]:
|
||||
- textbox "Password" [active] [ref=f54e24]: labpass
|
||||
- button "Show password" [ref=f54e26] [cursor=pointer]:
|
||||
- generic [aria-hidden] [ref=f54e27]:
|
||||
- button "Sign In" [ref=f54e30] [cursor=pointer]
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
|
||||
WORKDIR /workspace
|
||||
COPY pom.xml .
|
||||
RUN mvn --batch-mode dependency:go-offline
|
||||
COPY src src
|
||||
RUN mvn --batch-mode verify
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
WORKDIR /app
|
||||
COPY --from=build /workspace/target/keycloak-bff.jar app.jar
|
||||
USER spring:spring
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.16</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>keycloak-bff</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>keycloak-bff</name>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- B-1: Application Session 을 Redis 로 옮긴다.
|
||||
spring-session-data-redis 가 SessionRepository 를 갈아끼우고,
|
||||
spring-boot-starter-data-redis 가 연결(Lettuce)을 제공한다.
|
||||
둘 다 있어야 자동구성이 걸린다 — 하나만 넣으면 조용히 in-memory 로 남는다. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.session</groupId>
|
||||
<artifactId>spring-session-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- B-2: OAuth2AuthorizedClient 를 PostgreSQL 로 옮긴다.
|
||||
Q3 가 후보로 든 "Redis 와 JDBC 중 무엇" 에서 JDBC 쪽이며,
|
||||
JdbcOAuth2AuthorizedClientService 는 같은 인터페이스라
|
||||
컨트롤러를 바꾸지 않아도 된다. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>keycloak-bff</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class BffApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BffApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
|
||||
@RestController
|
||||
public class BffController {
|
||||
|
||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||
private final OAuth2AuthorizedClientManager authorizedClientManager;
|
||||
private final RestClient resourceApi;
|
||||
private final AtomicReference<String> theme = new AtomicReference<>("system");
|
||||
|
||||
public BffController(
|
||||
OAuth2AuthorizedClientService authorizedClientService,
|
||||
OAuth2AuthorizedClientManager authorizedClientManager,
|
||||
RestClient.Builder restClientBuilder,
|
||||
@Value("${resource-api.base-url}") String resourceApiBaseUrl
|
||||
) {
|
||||
this.authorizedClientService = authorizedClientService;
|
||||
this.authorizedClientManager = authorizedClientManager;
|
||||
this.resourceApi = restClientBuilder.baseUrl(resourceApiBaseUrl).build();
|
||||
}
|
||||
|
||||
@GetMapping("/bff/token-boundary")
|
||||
ResponseEntity<Map<String, Object>> tokenBoundary(Authentication authentication) {
|
||||
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
|
||||
"keycloak",
|
||||
authentication.getName()
|
||||
);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("pattern", "AP3-backend-for-frontend");
|
||||
response.put("principal", authentication.getName());
|
||||
response.put("accessTokenStoredOnServer", client != null
|
||||
&& client.getAccessToken() != null);
|
||||
response.put("refreshTokenStoredOnServer", client != null
|
||||
&& client.getRefreshToken() != null);
|
||||
response.put("browserTokenCount", 0);
|
||||
response.put("csrfProtectionEnabled", true);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header("Pragma", "no-cache")
|
||||
.body(response);
|
||||
}
|
||||
|
||||
@GetMapping("/bff/api/me")
|
||||
ResponseEntity<?> currentUser(Authentication authentication) {
|
||||
OAuth2AuthorizedClient client = authorizedClient(authentication);
|
||||
return resourceApi.get()
|
||||
.uri("/api/me")
|
||||
.header(
|
||||
HttpHeaders.AUTHORIZATION,
|
||||
"Bearer " + client.getAccessToken().getTokenValue()
|
||||
)
|
||||
.retrieve()
|
||||
.toEntity(Map.class);
|
||||
}
|
||||
|
||||
@PostMapping("/bff/api/preferences")
|
||||
Map<String, Object> updatePreference(
|
||||
Authentication authentication,
|
||||
@RequestParam(defaultValue = "system") String theme
|
||||
) {
|
||||
this.theme.set(theme);
|
||||
return Map.of(
|
||||
"updated", true,
|
||||
"theme", this.theme.get(),
|
||||
"principal", authentication.getName()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/bff/api/preferences")
|
||||
Map<String, String> preference() {
|
||||
return Map.of("theme", theme.get());
|
||||
}
|
||||
|
||||
private OAuth2AuthorizedClient authorizedClient(Authentication authentication) {
|
||||
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
|
||||
.withClientRegistrationId("keycloak")
|
||||
.principal(authentication)
|
||||
.build();
|
||||
OAuth2AuthorizedClient client = authorizedClientManager.authorize(request);
|
||||
if (client == null || client.getAccessToken() == null) {
|
||||
throw new ResponseStatusException(
|
||||
UNAUTHORIZED,
|
||||
"No authorized Keycloak client is available"
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class CsrfController {
|
||||
|
||||
@GetMapping("/bff/csrf")
|
||||
ResponseEntity<Map<String, String>> csrf(CsrfToken csrfToken) {
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header("Pragma", "no-cache")
|
||||
.body(Map.of(
|
||||
"headerName", csrfToken.getHeaderName(),
|
||||
"parameterName", csrfToken.getParameterName(),
|
||||
"token", csrfToken.getToken()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* B-2 — authorized client 를 프로세스 메모리에서 PostgreSQL 로 옮긴다.
|
||||
*
|
||||
* B-1 에서 Application Session 만 Redis 로 옮겼더니, 사용자는 로그인
|
||||
* 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다.
|
||||
* 두 상태의 저장소를 **각각** 정해야 한다는 Q3 의 지적이 그대로 나타난 것이다.
|
||||
*
|
||||
* 주의 — 이것이 고치는 것과 고치지 못하는 것이 다르다.
|
||||
* 고친다 : 인스턴스 간 공유. 어느 replica 로 가도 같은 토큰을 본다.
|
||||
* 못 고친다: 조회 키. JdbcOAuth2AuthorizedClientService 도
|
||||
* (clientRegistrationId, principalName) 으로 찾으므로
|
||||
* 같은 사용자의 두 브라우저는 여전히 한 항목을 공유한다.
|
||||
*/
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService authorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain bffSecurity(
|
||||
HttpSecurity http,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) throws Exception {
|
||||
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
|
||||
new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository,
|
||||
"/oauth2/authorization"
|
||||
);
|
||||
authorizationRequestResolver.setAuthorizationRequestCustomizer(
|
||||
OAuth2AuthorizationRequestCustomizers.withPkce()
|
||||
);
|
||||
|
||||
CookieCsrfTokenRepository csrfTokenRepository =
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
csrfTokenRepository.setCookiePath("/");
|
||||
|
||||
return http
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/index.html",
|
||||
"/app.js",
|
||||
"/favicon.ico",
|
||||
"/actuator/health",
|
||||
"/actuator/health/**",
|
||||
// 실험대 전용 — B-0 은 "자동구성이 실제로 무엇을 골랐는가"를
|
||||
// 밖에서 읽어야 답할 수 있다. 운영에서는 절대 열지 않는다:
|
||||
// /actuator/beans 와 /actuator/env 는 내부 구조와 설정값을
|
||||
// 그대로 드러낸다.
|
||||
"/actuator/**"
|
||||
)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.oauth2Login(oauth2 -> oauth2
|
||||
.authorizationEndpoint(endpoint -> endpoint
|
||||
.authorizationRequestResolver(authorizationRequestResolver))
|
||||
.defaultSuccessUrl("/", true))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
OAuth2AuthorizedClientManager authorizedClientManager(
|
||||
ClientRegistrationRepository clientRegistrationRepository,
|
||||
OAuth2AuthorizedClientService authorizedClientService
|
||||
) {
|
||||
OAuth2AuthorizedClientProvider authorizedClientProvider =
|
||||
OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
.authorizationCode()
|
||||
.refreshToken()
|
||||
.build();
|
||||
|
||||
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
|
||||
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
|
||||
clientRegistrationRepository,
|
||||
authorizedClientService
|
||||
);
|
||||
manager.setAuthorizedClientProvider(authorizedClientProvider);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
|
||||
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
|
||||
|
||||
private final CsrfTokenRequestHandler plain =
|
||||
new CsrfTokenRequestAttributeHandler();
|
||||
private final CsrfTokenRequestHandler xor =
|
||||
new XorCsrfTokenRequestAttributeHandler();
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Supplier<CsrfToken> deferredCsrfToken
|
||||
) {
|
||||
xor.handle(request, response, deferredCsrfToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String resolveCsrfTokenValue(
|
||||
HttpServletRequest request,
|
||||
CsrfToken csrfToken
|
||||
) {
|
||||
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
|
||||
return plain.resolveCsrfTokenValue(request, csrfToken);
|
||||
}
|
||||
return xor.resolveCsrfTokenValue(request, csrfToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
name: AP3_SESSION
|
||||
http-only: true
|
||||
same-site: lax
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: keycloak-bff
|
||||
datasource:
|
||||
# B-2: authorized client 전용. Keycloak 과 같은 PostgreSQL 인스턴스지만
|
||||
# 테이블이 다르다(oauth2_authorized_client). 운영이라면 분리를 검토한다.
|
||||
url: ${BFF_DB_URL:jdbc:postgresql://localhost:5432/keycloak}
|
||||
username: ${BFF_DB_USER:keycloak}
|
||||
password: ${BFF_DB_PASSWORD:keycloak}
|
||||
sql:
|
||||
init:
|
||||
# Spring Security 가 제공하는 DDL 을 그대로 쓴다.
|
||||
# always 로 두면 매 기동마다 실행되므로 CREATE TABLE IF NOT EXISTS 가 아닌
|
||||
# 스크립트에서는 실패한다 → continue-on-error 로 넘긴다.
|
||||
mode: ${SPRING_SQL_INIT_MODE:always}
|
||||
# ★ PostgreSQL 은 -postgres 판본을 써야 한다. 기본 판본은 `blob` 타입을
|
||||
# 쓰는데 PostgreSQL 에는 그 타입이 없다(`bytea` 다). continue-on-error 가
|
||||
# 그 실패를 삼켜서 "테이블이 조용히 안 생기는" 상태가 됐었다.
|
||||
schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql
|
||||
continue-on-error: true
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
session:
|
||||
# Application Session 만 Redis 로 간다. OAuth2AuthorizedClient 는
|
||||
# 이 설정과 무관하며 여전히 InMemory 다 — 조회 키가 다르기 때문이다(B-0).
|
||||
store-type: ${SPRING_SESSION_STORE_TYPE:redis}
|
||||
timeout: ${SPRING_SESSION_TIMEOUT:30m}
|
||||
redis:
|
||||
namespace: bff:session
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
keycloak:
|
||||
provider: keycloak
|
||||
client-id: bff-confidential
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET}
|
||||
client-authentication-method: client_secret_basic
|
||||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
|
||||
scope:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
provider:
|
||||
keycloak:
|
||||
# 브라우저가 리다이렉트되는 주소와 BFF 가 서버끼리 부르는 주소는 다르다.
|
||||
# 앞의 것은 외부에서 닿는 이름이어야 하고, 뒤의 것은 클러스터 안 주소여도 된다.
|
||||
authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth
|
||||
token-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/token
|
||||
jwk-set-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/certs
|
||||
user-info-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/userinfo
|
||||
user-name-attribute: preferred_username
|
||||
|
||||
resource-api:
|
||||
base-url: ${RESOURCE_API_BASE_URL:http://localhost:8081}
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
show-details: always
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
# beans / conditions 는 B-0 에서 "자동구성이 실제로 무엇을 골랐는가"를
|
||||
# 보기 위해 연다. 운영에 그대로 두면 내부 구조가 노출된다.
|
||||
include: health,info,beans,conditions,env
|
||||
@@ -0,0 +1,59 @@
|
||||
const result = document.querySelector("#result");
|
||||
|
||||
function render(value) {
|
||||
result.textContent = JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
const value = document.cookie
|
||||
.split("; ")
|
||||
.find((cookie) => cookie.startsWith(prefix));
|
||||
return value ? decodeURIComponent(value.slice(prefix.length)) : null;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: { Accept: "application/json", ...options.headers },
|
||||
});
|
||||
if (response.redirected || response.status === 401) {
|
||||
window.location.assign("/oauth2/authorization/keycloak");
|
||||
return null;
|
||||
}
|
||||
const body = await response.json();
|
||||
render({ status: response.status, ...body });
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
document.querySelector("#login").addEventListener("click", () => {
|
||||
window.location.assign("/oauth2/authorization/keycloak");
|
||||
});
|
||||
|
||||
document.querySelector("#inspect").addEventListener("click", () => {
|
||||
void request("/bff/token-boundary");
|
||||
});
|
||||
|
||||
document.querySelector("#call-bff").addEventListener("click", () => {
|
||||
void request("/bff/api/me");
|
||||
});
|
||||
|
||||
document.querySelector("#change-with-csrf").addEventListener("click", async () => {
|
||||
const csrfResponse = await fetch("/bff/csrf", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const csrf = await csrfResponse.json();
|
||||
const csrfToken = readCookie("XSRF-TOKEN");
|
||||
if (!csrfToken) {
|
||||
render({ status: 500, error: "XSRF-TOKEN cookie was not created" });
|
||||
return;
|
||||
}
|
||||
await request("/bff/api/preferences", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({ theme: "dark" }),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
[csrf.headerName]: csrfToken,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AP3 · Backend-for-Frontend</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { max-width: 58rem; margin: 6vh auto; padding: 0 1.5rem; line-height: 1.6; }
|
||||
button { margin: 0 0.5rem 0.5rem 0; padding: 0.6rem 0.9rem; cursor: pointer; }
|
||||
pre { min-height: 9rem; padding: 1rem; border-radius: 0.4rem;
|
||||
background: color-mix(in srgb, CanvasText 9%, Canvas); white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AP3 · Backend-for-Frontend</h1>
|
||||
<p>
|
||||
브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session
|
||||
cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource
|
||||
Server 요청에 붙입니다.
|
||||
</p>
|
||||
<button id="login" type="button">Keycloak 로그인</button>
|
||||
<button id="inspect" type="button">token 경계 확인</button>
|
||||
<button id="call-bff" type="button">BFF 경유 API 호출</button>
|
||||
<button id="change-with-csrf" type="button">CSRF token으로 상태 변경</button>
|
||||
<pre id="result" aria-live="polite"></pre>
|
||||
</main>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.keycloakpattern.bff;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
|
||||
// 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이
|
||||
// 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다.
|
||||
"spring.session.store-type=none",
|
||||
// 테스트에는 PostgreSQL 이 없다. H2 로 대신하고 Spring Security 의
|
||||
// DDL 을 그대로 태워 JdbcOAuth2AuthorizedClientService 가 뜨게 한다.
|
||||
"spring.datasource.url=jdbc:h2:mem:bfftest;DB_CLOSE_DELAY=-1",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.sql.init.mode=always",
|
||||
"resource-api.base-url=http://127.0.0.1:9"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
class BffControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private OAuth2AuthorizedClientService authorizedClientService;
|
||||
|
||||
@MockitoBean
|
||||
private OAuth2AuthorizedClientManager authorizedClientManager;
|
||||
|
||||
@Test
|
||||
void reportsServerTokenCustodyWithoutReturningTokens() throws Exception {
|
||||
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
|
||||
when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
|
||||
when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
|
||||
when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
|
||||
.thenReturn(client);
|
||||
|
||||
mockMvc.perform(get("/bff/token-boundary").with(oidcLogin()
|
||||
.idToken(token -> token.subject("test-subject"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
|
||||
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
|
||||
.andExpect(jsonPath("$.browserTokenCount").value(0))
|
||||
.andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
|
||||
.andExpect(jsonPath("$.access_token").doesNotExist())
|
||||
.andExpect(jsonPath("$.refresh_token").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStateChangeWithoutCsrfToken() throws Exception {
|
||||
mockMvc.perform(post("/bff/api/preferences")
|
||||
.param("theme", "attacker")
|
||||
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsStateChangeWithCsrfToken() throws Exception {
|
||||
mockMvc.perform(post("/bff/api/preferences")
|
||||
.param("theme", "dark")
|
||||
.with(oidcLogin().idToken(token -> token.subject("test-subject")))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(true))
|
||||
.andExpect(jsonPath("$.theme").value("dark"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesSpaCsrfTokenWithoutCaching() throws Exception {
|
||||
mockMvc.perform(get("/bff/csrf").with(oidcLogin()
|
||||
.idToken(token -> token.subject("test-subject"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(header().exists("Set-Cookie"))
|
||||
.andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN"))
|
||||
.andExpect(jsonPath("$.token").isNotEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# Experiment B-7 — oauth2-proxy, to measure how replicas share a cookie secret
|
||||
# and what happens when it is rotated (Q1, unknown 7).
|
||||
#
|
||||
# This is a different shape of problem from the BFF. The BFF keeps state on the
|
||||
# server, so the question was "which store". oauth2-proxy keeps no server state
|
||||
# at all: the whole session rides in a cookie that is signed and encrypted with
|
||||
# --cookie-secret. So there is nothing to share and nothing to lose on restart —
|
||||
# instead, every replica must hold the *same* secret, and changing it invalidates
|
||||
# every cookie at once.
|
||||
#
|
||||
# kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
#
|
||||
# app2.hyeonworks.com is borrowed from Grafana for the duration of this
|
||||
# experiment; the certificate only covers auth / app1 / app2, so a fourth name
|
||||
# is not available. Grafana's Ingress is restored afterwards.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: oauth2-proxy-secrets
|
||||
namespace: keycloak-lab
|
||||
type: Opaque
|
||||
stringData:
|
||||
# oauth2-proxy requires exactly 16, 24 or 32 bytes. This is the value whose
|
||||
# rotation the experiment is about.
|
||||
COOKIE_SECRET_A: "lab-cookie-secret-aaaaaaaaaaaaaa"
|
||||
COOKIE_SECRET_B: "lab-cookie-secret-bbbbbbbbbbbbbb"
|
||||
CLIENT_SECRET: proxy-lab-secret
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: oauth2-proxy
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
# Two replicas is the point: Q1 asks how they share the secret.
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels: { app: oauth2-proxy }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: oauth2-proxy }
|
||||
spec:
|
||||
# See B-1: Kubernetes injects <SVCNAME>_PORT as a tcp:// URL and it
|
||||
# collides with ordinary configuration names.
|
||||
enableServiceLinks: false
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels: { app: oauth2-proxy }
|
||||
containers:
|
||||
- name: oauth2-proxy
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
|
||||
args:
|
||||
- --provider=oidc
|
||||
- --oidc-issuer-url=https://auth.hyeonworks.com/realms/keycloak-patterns
|
||||
- --client-id=oauth2-proxy
|
||||
- --redirect-url=https://app2.hyeonworks.com/oauth2/callback
|
||||
- --email-domain=*
|
||||
- --http-address=0.0.0.0:4180
|
||||
# The upstream is the same echo app the B-4 header experiment used,
|
||||
# so what the proxy forwards can be read straight off the response.
|
||||
- --upstream=http://echo.header-lab.svc:8081
|
||||
# ★ 이 옵션을 켜면 세션(=쿠키)에 access token 이 들어간다.
|
||||
# 그러면 Set-Cookie 가 커져 프록시 앞단에서 502 가 났다.
|
||||
# B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타난 것이다.
|
||||
# - --pass-authorization-header=true
|
||||
- --set-xauthrequest=true
|
||||
- --reverse-proxy=true
|
||||
- --cookie-secure=true
|
||||
# One hour, matching the value Q1 records for the current setup.
|
||||
- --cookie-expire=1h
|
||||
- --skip-provider-button=true
|
||||
# ★ 쿠키에 세션 전체를 담으면 Set-Cookie 가 커지고, 그 응답이
|
||||
# 앞단 nginx 의 proxy_buffer 를 넘겨 502 가 났다(측정됨).
|
||||
# Redis 로 옮기면 쿠키에는 티켓만 남는다 — 그리고 그 순간
|
||||
# "replica 가 secret 을 공유해야 한다"는 문제의 성격도 바뀐다.
|
||||
- --session-store-type=redis
|
||||
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
|
||||
env:
|
||||
- name: OAUTH2_PROXY_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: oauth2-proxy-secrets, key: CLIENT_SECRET }
|
||||
# Which of the two secrets is in use is switched here. Both replicas
|
||||
# read the same key, which is exactly the sharing Q1 asks about.
|
||||
- name: OAUTH2_PROXY_COOKIE_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: oauth2-proxy-secrets, key: COOKIE_SECRET_A }
|
||||
ports:
|
||||
- containerPort: 4180
|
||||
name: http
|
||||
readinessProbe:
|
||||
httpGet: { path: /ping, port: http }
|
||||
initialDelaySeconds: 5
|
||||
resources:
|
||||
requests: { memory: 32Mi, cpu: 20m }
|
||||
limits: { memory: 128Mi }
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: oauth2-proxy
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
selector: { app: oauth2-proxy }
|
||||
ports:
|
||||
- port: 4180
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: oauth2-proxy
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: app2.hyeonworks.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: oauth2-proxy
|
||||
port:
|
||||
number: 4180
|
||||
@@ -0,0 +1,214 @@
|
||||
# BFF (2 replicas) + Redis, for the B-layer experiments.
|
||||
#
|
||||
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
|
||||
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
|
||||
# configured, and the only honest way to answer is to look at a running instance
|
||||
# that has been given nothing. Redis is deployed alongside but left unused until
|
||||
# B-1 turns it on.
|
||||
#
|
||||
# kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||
#
|
||||
# Image comes from the workstation, not a registry:
|
||||
# docker build -t keycloak-pattern-bff:lab bff/
|
||||
# docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||
# (repeat for kc-lab-2)
|
||||
# so imagePullPolicy must stay Never on both replicas.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bff-secrets
|
||||
namespace: keycloak-lab
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Matches the client created with kcadm in the keycloak-patterns realm.
|
||||
# Base64 in etcd is not encryption — see D-3.
|
||||
KEYCLOAK_CLIENT_SECRET: bff-lab-secret
|
||||
---
|
||||
# Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
|
||||
# nothing here, because /data is the container filesystem and dies with the
|
||||
# container — the appendonlydir was created and then thrown away. Persistence
|
||||
# configuration without a volume is decoration.
|
||||
#
|
||||
# So the volume comes first, and only then does `--appendonly yes` mean anything.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: redis-data
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: { app: redis }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: redis }
|
||||
spec:
|
||||
# Same node as postgres so a node-loss experiment takes both stores at
|
||||
# once, matching how A-4 was set up.
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: kc-lab-2
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7.4-alpine
|
||||
# appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
|
||||
# Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
|
||||
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
readinessProbe:
|
||||
exec: { command: ["redis-cli", "ping"] }
|
||||
initialDelaySeconds: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests: { memory: 32Mi, cpu: 20m }
|
||||
limits: { memory: 128Mi }
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
selector: { app: redis }
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: redis
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bff
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
# Two replicas is the whole point: Q1 and Q2 only exist because a request can
|
||||
# land on an instance that did not handle the login.
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels: { app: bff }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: bff }
|
||||
spec:
|
||||
# Spread across both nodes so "the other instance" is genuinely another
|
||||
# machine, not another process on the same kernel.
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels: { app: bff }
|
||||
# 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의
|
||||
# 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379.
|
||||
# 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다.
|
||||
# Failed to bind properties under 'spring.data.redis.port' to int:
|
||||
# Value: "tcp://10.43.57.116:6379"
|
||||
# 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이
|
||||
# 같은 함정에 다시 빠진다.
|
||||
enableServiceLinks: false
|
||||
containers:
|
||||
- name: bff
|
||||
image: keycloak-pattern-bff:lab
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8083
|
||||
name: http
|
||||
env:
|
||||
# The browser is redirected to the public name; the BFF calls the
|
||||
# token endpoint over the cluster network. Getting these two the same
|
||||
# way round is what the 2-hop header experiment was about.
|
||||
- name: KC_ISSUER_EXTERNAL
|
||||
value: https://auth.hyeonworks.com/realms/keycloak-patterns
|
||||
- name: KC_ISSUER_INTERNAL
|
||||
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
|
||||
# echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의
|
||||
# 서비스는 <svc>.<ns>.svc 로 부른다. 이름을 틀리면 500 이 나는데
|
||||
# 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다.
|
||||
- name: RESOURCE_API_BASE_URL
|
||||
value: http://echo.header-lab.svc:8081
|
||||
- name: KEYCLOAK_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
|
||||
# Spring needs to know it is behind TLS termination, for the same
|
||||
# reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri
|
||||
# it builds comes back as http:// and Keycloak rejects it.
|
||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||
value: native
|
||||
# B-1: Application Session 을 Redis 로 옮긴다.
|
||||
# OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가
|
||||
# 다르기 때문이며, B-0 에서 확인한 사실이다.
|
||||
- name: SPRING_SESSION_STORE_TYPE
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis.keycloak-lab.svc
|
||||
- name: REDIS_PORT
|
||||
value: "6379"
|
||||
# B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른
|
||||
# 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다.
|
||||
- name: BFF_DB_URL
|
||||
value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak
|
||||
- name: BFF_DB_USER
|
||||
value: keycloak
|
||||
- name: BFF_DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD }
|
||||
- name: JAVA_TOOL_OPTIONS
|
||||
value: "-Xms128m -Xmx320m"
|
||||
readinessProbe:
|
||||
httpGet: { path: /actuator/health/readiness, port: http }
|
||||
initialDelaySeconds: 20
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet: { path: /actuator/health/liveness, port: http }
|
||||
initialDelaySeconds: 60
|
||||
resources:
|
||||
requests: { memory: 320Mi, cpu: 100m }
|
||||
limits: { memory: 512Mi }
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bff
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
selector: { app: bff }
|
||||
ports:
|
||||
- port: 8083
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: bff
|
||||
namespace: keycloak-lab
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: app1.hyeonworks.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: bff
|
||||
port:
|
||||
number: 8083
|
||||
@@ -0,0 +1,18 @@
|
||||
=== 비교를 위해 세션을 비운다 ===
|
||||
DELETE 151
|
||||
|
||||
=== volatile 모드로 전환 ===
|
||||
namespace/keycloak-lab unchanged
|
||||
secret/keycloak-lab-secrets configured
|
||||
persistentvolumeclaim/postgres-data unchanged
|
||||
deployment.apps/postgres unchanged
|
||||
service/postgres unchanged
|
||||
statefulset.apps/keycloak configured
|
||||
service/keycloak-headless unchanged
|
||||
service/keycloak unchanged
|
||||
ingress.networking.k8s.io/keycloak unchanged
|
||||
Waiting for 1 pods to be ready...
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
|
||||
=== [검증] 정말 꺼졌는가 ===
|
||||
["start","--features-disabled=persistent-user-sessions"]
|
||||
@@ -0,0 +1,15 @@
|
||||
keycloak-0=10.42.1.94 keycloak-1=10.42.0.45
|
||||
|
||||
=== [A-0 재실행] keycloak-0 에만 로그인 5회 → 캐시가 어디에 담기는가 ===
|
||||
로그인완료
|
||||
keycloak-0 sessions 캐시 5.0 건
|
||||
keycloak-1 sessions 캐시 0.0 건
|
||||
|
||||
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
|
||||
offline_flag | count
|
||||
--------------+-------
|
||||
(0 rows)
|
||||
|
||||
|
||||
=== 교차 노드 세션은 되는가 ===
|
||||
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
|
||||
@@ -0,0 +1,13 @@
|
||||
=== [A-8 재실행] 재시작 전 로그인 ===
|
||||
sid = aVwYnzKZFFvMqD3bpSeiILuM
|
||||
|
||||
=== 롤링 재시작 ===
|
||||
statefulset.apps/keycloak restarted
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
|
||||
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
|
||||
keycloak-0 에서 refresh HTTP 400
|
||||
--- 오류 본문 ---
|
||||
{"error":"invalid_grant","error_description":"Session not active"}
|
||||
=== 캐시 상태 ===
|
||||
keycloak-1 sessions 캐시 1.0 건
|
||||
@@ -0,0 +1,21 @@
|
||||
=== [A-1 재실행] volatile 에서 7800 을 막으면 ===
|
||||
keycloak-0=10.42.1.99 keycloak-1=10.42.0.46
|
||||
[대조군] 차단 전 교차 노드 refresh
|
||||
keycloak-1 에서 refresh HTTP 200
|
||||
|
||||
차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
|
||||
분단이 성립할 때까지 대기...
|
||||
+25초 cluster_size(k0 k1) = [2.0 2.0 ]
|
||||
+50초 cluster_size(k0 k1) = [1.0 ]
|
||||
+75초 cluster_size(k0 k1) = [1.0 ]
|
||||
+100초 cluster_size(k0 k1) = []
|
||||
+125초 cluster_size(k0 k1) = [1.0 ]
|
||||
+150초 cluster_size(k0 k1) = [1.0 ]
|
||||
+175초 cluster_size(k0 k1) = [1.0 ]
|
||||
+200초 cluster_size(k0 k1) = []
|
||||
|
||||
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
|
||||
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
|
||||
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
|
||||
--- 시험군 오류 본문 ---
|
||||
{"error":"invalid_grant","error_description":"Session not active"}
|
||||
@@ -0,0 +1,12 @@
|
||||
차단 해제, 클러스터 재형성 대기...
|
||||
|
||||
=== [A-2 재실행] volatile 에서 DB 를 내리면 — 세션이 메모리에 있으니 살아남는가? ===
|
||||
DB 정지 전 로그인 완료
|
||||
deployment.apps/postgres scaled
|
||||
postgres 정지
|
||||
① 캐시를 가진 노드에서 refresh HTTP 500
|
||||
② 새 로그인 HTTP 200
|
||||
|
||||
=== DB 복구 후 원복 ===
|
||||
deployment.apps/postgres scaled
|
||||
deployment "postgres" successfully rolled out
|
||||
@@ -0,0 +1,12 @@
|
||||
=== persistent 모드로 원복 ===
|
||||
statefulset.apps/keycloak configured
|
||||
partitioned roll out complete: 2 new pods have been updated...
|
||||
|
||||
=== [검증] persistent 로 돌아왔는가 — 로그인 후 DB 에 행이 생기는가 ===
|
||||
["start"]
|
||||
로그인
|
||||
DB 온라인 세션: 1 건 (1 이면 persistent 복귀)
|
||||
keycloak-0 1/1 Running 0 67s
|
||||
keycloak-1 1/1 Running 0 89s
|
||||
postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
|
||||
외부 진입점 HTTP 200
|
||||
@@ -0,0 +1,23 @@
|
||||
# A-7 — volatile 모드 비교 증거
|
||||
|
||||
2026-09-04 13:45–14:15 KST
|
||||
해설: [`docs/experiment-a7-volatile-comparison.md`](../../experiment-a7-volatile-comparison.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-switch-to-volatile.txt` | `--features-disabled=persistent-user-sessions` 적용, args 확인 |
|
||||
| `02-a0-rerun.txt` | **DB 0건**인데 교차 노드 refresh `200` — 경로가 DB 에서 클러스터로 바뀌었다 |
|
||||
| `03-a8-rerun-restart.txt` | **롤링 재시작 후 `400 Session not active`** — persistent 에서는 `200` 이었다 |
|
||||
| `04-a1-rerun-partition.txt` | **7800 차단 시 교차 노드 `400`** — persistent 에서는 `200`. 대조군(같은 노드)은 `200` 유지 |
|
||||
| `05-a2-rerun-db-loss.txt` | DB 정지 중 **새 로그인 `200`**(persistent 에서는 500), refresh 는 `500` |
|
||||
| `06-restore-persistent.txt` | 원복 확인 — `args: ["start"]`, 로그인 후 DB 1건, 외부 200 |
|
||||
|
||||
## 뒤집힌 결과
|
||||
|
||||
| 실험 | persistent | volatile |
|
||||
|---|---|---|
|
||||
| A-1 7800 차단 후 교차 refresh | `200` | **`400`** |
|
||||
| A-8 롤링 재시작 후 refresh | `200` | **`400`** |
|
||||
| A-2 DB 정지 중 새 로그인 | `500` | **`200`** |
|
||||
|
||||
**같은 주입, 같은 관측, 정반대 결과.** A층 전체가 버전 조건부임을 보여주는 대조군이다.
|
||||
@@ -0,0 +1,21 @@
|
||||
=== 배포 전 자원 ===
|
||||
Mem: 11648 7329 280 4 4377 4319
|
||||
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
|
||||
kc-lab-1 115m 5% 2192Mi 44%
|
||||
kc-lab-2 121m 6% 1324Mi 33%
|
||||
|
||||
=== 배포 ===
|
||||
secret/bff-secrets created
|
||||
deployment.apps/redis created
|
||||
service/redis created
|
||||
deployment.apps/bff created
|
||||
service/bff created
|
||||
ingress.networking.k8s.io/bff created
|
||||
|
||||
deployment "redis" successfully rolled out
|
||||
Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available...
|
||||
deployment "bff" successfully rolled out
|
||||
|
||||
bff-574c6d658b-8cz4x true kc-lab-1
|
||||
bff-574c6d658b-zpkbp true kc-lab-2
|
||||
redis-568bd7c4-5c5vc true kc-lab-2
|
||||
@@ -0,0 +1,13 @@
|
||||
=== B-0: 자동구성이 실제로 고른 구현체 ===
|
||||
Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에,
|
||||
어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다"
|
||||
|
||||
File "<stdin>", line 9
|
||||
print(f" {name:46} {t.rsplit(\".\",1)[-1]}")
|
||||
^
|
||||
SyntaxError: unexpected character after line continuation character
|
||||
|
||||
=== HttpSession 은 어디에 있는가 (서블릿 컨테이너 기본) ===
|
||||
|
||||
=== 외부 진입점 ===
|
||||
https://app1.hyeonworks.com/ HTTP 200
|
||||
@@ -0,0 +1,29 @@
|
||||
=== B-0 — 자동구성이 실제로 고른 구현체 ===
|
||||
컨텍스트: keycloak-bff
|
||||
전체 빈 수: 321
|
||||
|
||||
--- 세션 · 토큰 저장소 관련 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
|
||||
|
||||
--- OAuth2 클라이언트 관련 전체 ---
|
||||
authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar
|
||||
authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
authorizedClientService -> InMemoryOAuth2AuthorizedClientService
|
||||
clientRegistrationRepository -> InMemoryClientRegistrationRepository
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration -> OAuth2ClientAutoConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration -> OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration
|
||||
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration -> OAuth2ClientWebSecurityAutoConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration -> OAuth2ClientConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration
|
||||
org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration -> OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration
|
||||
spring.security.oauth2.client-org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -> OAuth2ClientProperties
|
||||
|
||||
--- Redis / Spring Session 이 구성되었는가 ---
|
||||
★ 없음 — Redis 도 Spring Session 도 구성되지 않았다
|
||||
@@ -0,0 +1,18 @@
|
||||
# B-0 — BFF·Redis 배포와 자동구성 확인 증거
|
||||
|
||||
2026-09-04 14:20–14:50 KST
|
||||
해설: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-deploy.txt` | 배포 전 자원, Redis·BFF 롤아웃, 두 노드에 하나씩 배치됨 |
|
||||
| `02-autoconfiguration.txt` | 첫 조회 시도(파싱 실패)와 **외부 진입점 `HTTP 200`** |
|
||||
| `03-beans-analysis.txt` | **B-0 의 답** — `InMemoryOAuth2AuthorizedClientService`, `AuthenticatedPrincipalOAuth2AuthorizedClientRepository`, **Redis·Spring Session 없음** |
|
||||
| `b0-bff-login-success-single-replica.png` | replica 1 에서 로그인 성공한 BFF 화면 |
|
||||
| `b0-bff-token-boundary.png` | `/bff/token-boundary` — `principal: labuser`, `accessTokenStoredOnServer: true`, **`browserTokenCount: 0`** |
|
||||
|
||||
## 핵심 세 줄
|
||||
|
||||
1. **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** — 조회 키가 principal 이고 session ID 가 없다. Q1·Q3 문제의 기제가 이 빈 하나에 있다.
|
||||
2. **Redis 를 붙여도 그건 안 고쳐진다.** 저장소 공유와 조회 키는 다른 문제다.
|
||||
3. **replica 2개에서는 로그인 자체가 실패한다.** 인가 코드 흐름의 왕복 두 번이 같은 인스턴스로 가야 하는데, 인가 요청이 인스턴스 메모리에 있다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,5 @@
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-576d869c6d-bshvl true kc-lab-2
|
||||
bff-695646ddb-kzs9k true kc-lab-1
|
||||
bff-695646ddb-vjqzf true kc-lab-2
|
||||
@@ -0,0 +1,64 @@
|
||||
=== B-1 — Redis 를 붙인 뒤 자동구성이 실제로 바뀌었는가 ===
|
||||
빈 수: 321 → 402 (+81)
|
||||
|
||||
--- 세션 저장소 관련 (새로 생긴 것) ---
|
||||
★ cookieSerializer -> DefaultCookieSerializer
|
||||
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration -> SessionAutoConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration -> SessionRepositoryFilterConfiguration
|
||||
★ org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration -> SpringHttpSessionConfiguration
|
||||
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
|
||||
★ rememberMeServicesCookieSerializerCustomizer -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration$$Lambda/0x00007f364e69fa60
|
||||
★ sessionEventHttpSessionListenerAdapter -> SessionEventHttpSessionListenerAdapter
|
||||
★ sessionRepository -> RedisSessionRepository
|
||||
★ sessionRepositoryFilterRegistration -> DelegatingFilterProxyRegistrationBean
|
||||
★ spring.session-org.springframework.boot.autoconfigure.session.SessionProperties -> SessionProperties
|
||||
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
|
||||
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
|
||||
★ springSessionRepositoryFilter -> SessionRepositoryFilter
|
||||
|
||||
--- OAuth2 authorized client — 바뀌었는가? ---
|
||||
authorizedClientService
|
||||
before: InMemoryOAuth2AuthorizedClientService
|
||||
after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientRepository
|
||||
before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository
|
||||
after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다
|
||||
authorizedClientManager
|
||||
before: AuthorizedClientServiceOAuth2AuthorizedClientManager
|
||||
after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다
|
||||
|
||||
--- Redis 연결 빈 (새로 생긴 것) ---
|
||||
★ keyValueMappingContext -> RedisMappingContext
|
||||
★ lettuceMetrics -> LettuceMetricsAutoConfiguration$$Lambda/0x00007f364e56f4d0
|
||||
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration -> RedisHealthContributorAutoConfiguration
|
||||
★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration -> RedisReactiveHealthContributorAutoConfiguration
|
||||
★ org.springframework.boot.actuate.autoconfigure.metrics.redis.LettuceMetricsAutoConfiguration -> LettuceMetricsAutoConfiguration
|
||||
★ org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration -> LettuceConnectionConfiguration
|
||||
★ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration -> RedisAutoConfiguration
|
||||
★ org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration -> RedisReactiveAutoConfiguration
|
||||
★ org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration -> RedisRepositoriesAutoConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration
|
||||
★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration
|
||||
★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration
|
||||
★ reactiveRedisTemplate -> ReactiveRedisTemplate
|
||||
★ reactiveStringRedisTemplate -> ReactiveStringRedisTemplate
|
||||
★ redisConnectionDetails -> PropertiesRedisConnectionDetails
|
||||
★ redisConnectionFactory -> LettuceConnectionFactory
|
||||
★ redisConverter -> MappingRedisConverter
|
||||
★ redisCustomConversions -> RedisCustomConversions
|
||||
★ redisHealthContributor -> RedisReactiveHealthIndicator
|
||||
★ redisKeyValueAdapter -> RedisKeyValueAdapter
|
||||
★ redisKeyValueTemplate -> RedisKeyValueTemplate
|
||||
★ redisMappingConfiguration#0 -> MappingConfiguration
|
||||
★ redisReferenceResolver -> ReferenceResolverImpl
|
||||
★ redisTemplate -> RedisTemplate
|
||||
★ sessionRepository -> RedisSessionRepository
|
||||
★ spring.data.redis-org.springframework.boot.autoconfigure.data.redis.RedisProperties -> RedisProperties
|
||||
★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties
|
||||
★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68
|
||||
★ stringRedisTemplate -> StringRedisTemplate
|
||||
@@ -0,0 +1,21 @@
|
||||
=== Redis 에 무엇이 들어 있는가 ===
|
||||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
총 키 수: 1
|
||||
|
||||
=== 세션 키의 내용 — refresh token 이 있는가 (Q3 검증 2번) ===
|
||||
키: bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||||
타입: hash
|
||||
필드: sessionAttr:SPRING_SECURITY_CONTEXT
|
||||
필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST
|
||||
필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION
|
||||
필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST
|
||||
필드: lastAccessedTime
|
||||
필드: maxInactiveInterval
|
||||
필드: creationTime
|
||||
|
||||
=== 필드 값에 토큰 문자열이 보이는가 ===
|
||||
1) "sessionAttr:SPRING_SECURITY_CONTEXT"
|
||||
2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x02L\x00\x1eauthorizedClientRegistrationIdt\x00\x12Ljava/lang/String;L\x00\tprincipalt\x00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xr\x00Gorg.springframework.security.authentication.AbstractAuthenticationToken\xd3\xaa(~nGd\x0e\x02\x00\x03Z\x00\rauthenticatedL\x00\x0bauthoritiest\x00\x16Ljava/util/Collection;L\x00\adetailst\x00\x12Ljava/lang/Object;xp\x01sr\x00&java.util.Collections$UnmodifiableList\xfc\x0f%1\xb5\xec\x8e\x10\x02\x00\x01L\x00\x04listt\x00\x10Ljava/util/List;xr\x00,java.util.Collect
|
||||
|
||||
=== TTL (Q3 검증 3번 — session TTL) ===
|
||||
TTL: 1772 초
|
||||
@@ -0,0 +1,18 @@
|
||||
# B-1 — Redis 세션 저장소 전환 증거
|
||||
|
||||
2026-09-04 14:50–15:05 KST
|
||||
해설: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-servicelinks-trap.txt` | `enableServiceLinks: false` 적용 후 롤아웃 성공 — 쿠버네티스가 주입한 `REDIS_PORT=tcp://...` 가 설정을 덮어쓴 문제 |
|
||||
| `02-autoconfig-after.txt` | **핵심** — 빈 321→402(+81). `sessionRepository → RedisSessionRepository` 로 바뀌었지만 **`authorizedClientService` 는 `InMemory` 그대로** |
|
||||
| `03-redis-contents.txt` | Redis 키 1개, 필드는 `SPRING_SECURITY_CONTEXT` 뿐. **토큰 없음.** Java 직렬화(`\xac\xed`), TTL 1772초 |
|
||||
| `b1-login-works-two-replicas.png` | 전환 직후 `accessTokenStoredOnServer: false` |
|
||||
| `b1-token-boundary-after-redis.png` | 파드 전면 교체 후 — `principal: labuser` 는 살아남고 토큰만 사라진 상태 |
|
||||
|
||||
## 핵심 세 줄
|
||||
|
||||
1. **세션은 옮겨졌고 토큰은 안 옮겨졌다.** 빈 81개가 늘었는데 authorized client 관련은 하나도 안 바뀌었다.
|
||||
2. **refresh token 은 Redis 에 평문으로 있는 게 아니라 아예 없다.** 암호화를 고민하기 전에 이걸 알아야 한다.
|
||||
3. **"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다** — 완전 로그아웃보다 나쁘다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,8 @@
|
||||
deployment.apps/bff configured
|
||||
deployment "bff" successfully rolled out
|
||||
bff-555df79c97-6j86w 1/1 Running 0 44s
|
||||
bff-555df79c97-vgg6g 1/1 Running 0 22s
|
||||
|
||||
=== oauth2_authorized_client 테이블이 생겼는가 ===
|
||||
Did not find any relation named "oauth2_authorized_client".
|
||||
command terminated with exit code 1
|
||||
@@ -0,0 +1,33 @@
|
||||
=== PostgreSQL 전용 스키마 ===
|
||||
CREATE TABLE oauth2_authorized_client (
|
||||
client_registration_id varchar(100) NOT NULL,
|
||||
principal_name varchar(200) NOT NULL,
|
||||
access_token_type varchar(100) NOT NULL,
|
||||
access_token_value bytea NOT NULL,
|
||||
access_token_issued_at timestamp NOT NULL,
|
||||
access_token_expires_at timestamp NOT NULL,
|
||||
access_token_scopes varchar(1000) DEFAULT NULL,
|
||||
refresh_token_value bytea DEFAULT NULL,
|
||||
refresh_token_issued_at timestamp DEFAULT NULL,
|
||||
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (client_registration_id, principal_name)
|
||||
);
|
||||
|
||||
=== 적용 ===
|
||||
CREATE TABLE
|
||||
Table "public.oauth2_authorized_client"
|
||||
Column | Type | Collation | Nullable | Default
|
||||
-------------------------+-----------------------------+-----------+----------+-------------------------
|
||||
client_registration_id | character varying(100) | | not null |
|
||||
principal_name | character varying(200) | | not null |
|
||||
access_token_type | character varying(100) | | not null |
|
||||
access_token_value | bytea | | not null |
|
||||
access_token_issued_at | timestamp without time zone | | not null |
|
||||
access_token_expires_at | timestamp without time zone | | not null |
|
||||
access_token_scopes | character varying(1000) | | | NULL::character varying
|
||||
refresh_token_value | bytea | | |
|
||||
refresh_token_issued_at | timestamp without time zone | | |
|
||||
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
|
||||
Indexes:
|
||||
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
|
||||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
|
||||
|
||||
=== access token 도 ===
|
||||
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPWS1jYVlETkdvUDRITUF6LVE5VVBUVS1ETTFpODk2TnV6VVp1NmdmQ3FNIn0.eyJleHAi
|
||||
|
||||
=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
|
||||
File "<string>", line 3
|
||||
h=open(/tmp/hdr.txt).read().strip()
|
||||
^
|
||||
SyntaxError: invalid syntax
|
||||
|
||||
=== 저장된 바이트를 그대로 디코드한 결과 ===
|
||||
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
|
||||
refresh_token 페이로드(앞부분):
|
||||
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||||
|
||||
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
|
||||
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
|
||||
@@ -0,0 +1,25 @@
|
||||
=== [현재] 같은 사용자의 항목 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
|
||||
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
|
||||
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
|
||||
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
|
||||
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
|
||||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||||
------------------------+----------------+----------------------------+----------------------------------
|
||||
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
|
||||
(1 row)
|
||||
|
||||
행 수: 1
|
||||
|
||||
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
|
||||
|
||||
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
|
||||
로그아웃 전
|
||||
Redis: 1 키
|
||||
PostgreSQL: 1 행
|
||||
@@ -0,0 +1,14 @@
|
||||
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
|
||||
Redis 세션 : 0 키
|
||||
PostgreSQL 토큰 : 1 행
|
||||
|
||||
principal_name | access_token_issued_at | access_token_expires_at
|
||||
----------------+----------------------------+----------------------------
|
||||
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
|
||||
(1 row)
|
||||
|
||||
|
||||
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
|
||||
|
||||
=== Keycloak 쪽 SSO 세션은? ===
|
||||
Keycloak 온라인 세션: 2
|
||||
@@ -0,0 +1,21 @@
|
||||
# B-2 — 다중 인스턴스 운영 증거
|
||||
|
||||
2026-09-04 15:05–15:15 KST
|
||||
해설: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-jdbc-store-deploy.txt` | JDBC 저장소로 배포. **테이블이 조용히 안 만들어졌다** |
|
||||
| `02-schema.txt` | 원인 — 기본 DDL 은 `blob`(PostgreSQL 에 없음), `-postgres.sql` 판본이 따로 있다. **`PRIMARY KEY (client_registration_id, principal_name)`** — 조회 키 문제가 DDL 에 박혀 있다 |
|
||||
| `03-plaintext-tokens.txt` | **Q3 검증 2번** — `bytea` 안이 JWT 문자열 그대로. 디코드하면 `{"alg":"HS512",...}` |
|
||||
| `04-overwrite-test.txt` | **Q1 검증 3번** — 같은 사용자 재로그인 시 행 수 1 그대로, `issued_at` 과 md5 만 바뀜 = **UPDATE(덮어쓰기)** |
|
||||
| `05-logout-cleanup.txt` | **Q1 검증 4번** — Redis 0키 / PostgreSQL **1행 잔존** / Keycloak SSO **2세션 잔존** |
|
||||
| `b2-before-relogin.png` | JDBC 전환 직후, 옛 세션은 여전히 `false` |
|
||||
| `b2-tokens-shared-across-instances.png` | 재로그인 후 **`accessTokenStoredOnServer: true`** — 두 replica 에서 동작 |
|
||||
|
||||
## 핵심 네 줄
|
||||
|
||||
1. **세션 Redis + 토큰 PostgreSQL 분리 저장이 성립한다.** B-1 의 "로그인은 됐는데 토큰이 없는" 상태가 해결됐다.
|
||||
2. **refresh token 은 평문이다.** DB 읽기 권한이면 작동하는 토큰을 얻는다.
|
||||
3. **같은 사용자의 두 번째 로그인이 첫 번째를 덮어쓴다.** 기본키에 session id 가 없어 구조적으로 그렇다.
|
||||
4. **로그아웃은 셋 중 하나만 지운다.** 평문 토큰과 Keycloak SSO 세션이 남는다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,11 @@
|
||||
=== [1] refresh token 하나 확보 ===
|
||||
토큰 길이: 811
|
||||
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
|
||||
sid: BvFiB01Rntz1FcLdf7zG4BNt
|
||||
|
||||
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
|
||||
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
|
||||
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 5: HTTP 200 {"access_token":"...(발급됨)
|
||||
@@ -0,0 +1,16 @@
|
||||
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||||
새 refresh token 길이: 810
|
||||
그 토큰으로 다시 갱신: HTTP 400
|
||||
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
|
||||
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
|
||||
user_session_id | offline_flag | last_session_refresh
|
||||
--------------------------+--------------+----------------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
|
||||
(1 row)
|
||||
|
||||
=== [5] revoked_token 테이블 ===
|
||||
revoked_count
|
||||
---------------
|
||||
0
|
||||
(1 row)
|
||||
@@ -0,0 +1,14 @@
|
||||
=== user session 과 client session 을 나눠서 본다 ===
|
||||
user_session_id | offline_flag | client_sessions
|
||||
--------------------------+--------------+-----------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||||
(1 row)
|
||||
|
||||
|
||||
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||||
새 sid: JT-XuepgutWcE273QwAnIXta
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
JT-XuepgutWcE273QwAnIXta | 1
|
||||
(1 row)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
=== 구성 A: rotation ON (revokeRefreshToken=true, maxReuse=0) — 앞서 측정 ===
|
||||
성공 1 / 5, 세션 파괴됨
|
||||
|
||||
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
|
||||
sid=iW1CGyO7COdyJLryIrCt3njk
|
||||
1: 200
|
||||
2: 200
|
||||
3: 200
|
||||
4: 200
|
||||
5: 200
|
||||
성공 5 / 5
|
||||
이긴 토큰 재사용: HTTP 200
|
||||
남은 client_session: 1
|
||||
|
||||
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
|
||||
sid=72c04JCdr0NpCHGQmXWW2wM8
|
||||
1: 200
|
||||
2: 400 "error_description":"Session doesn't have required client"
|
||||
3: 200
|
||||
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
|
||||
5: 400 "error_description":"Session doesn't have required client"
|
||||
성공 2 / 5
|
||||
이긴 토큰 재사용: HTTP 400
|
||||
남은 client_session: 0
|
||||
@@ -0,0 +1,18 @@
|
||||
# B-3 — Refresh Token 동시 갱신 경쟁 증거
|
||||
|
||||
2026-09-04 15:15–15:25 KST
|
||||
해설: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-concurrent-refresh.txt` | 같은 토큰으로 동시 5회 — **1개만 200**, 나머지는 `Maximum allowed refresh token reuse exceeded` 와 `Session doesn't have required client` **두 종류** 오류 |
|
||||
| `02-session-impact.txt` | **★ 이긴 요청의 새 토큰조차 400.** user_session 행은 남아 있고 `revoked_token` 은 0건 |
|
||||
| `03-client-session-removed.txt` | **기제 확정 (대조군 포함)** — 경쟁 세션 `client_sessions=0`, 정상 세션 `client_sessions=1` |
|
||||
| `04-policy-comparison.txt` | 정책 3종 비교 — rotation OFF 는 **5/5 성공·세션 생존**, maxReuse=1 은 **여전히 세션 파괴** |
|
||||
|
||||
## 핵심 네 줄
|
||||
|
||||
1. **"하나는 성공"이 아니다.** 이긴 요청이 받은 토큰도 곧바로 쓸 수 없다.
|
||||
2. **재사용 탐지가 client session 을 제거한다.** user session 은 껍데기로 남아 `Session doesn't have required client` 가 된다.
|
||||
3. **`refreshTokenMaxReuse` 를 올려도 안 된다.** 동시 요청 수만큼 올려야 하고 그러면 rotation 의 목적이 사라진다.
|
||||
4. **재시도로 회복되지 않으므로 Q2 의 답은 lock 이다.** 그리고 lock 은 저장소 쪽(가급적 DB 행 잠금)에 있어야 한다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user