์ธ์
๊ณผ JWT ํ ํฐ์ ์๋ช
์ฃผ๊ธฐ๋ฅผ ํจ๊ณผ์ ์ผ๋ก ๊ด๋ฆฌํ๋ ๋ฐฉ๋ฒ์ ์์๋ด
๋๋ค. ์ธ์
๊ด๋ฆฌ๋ ์ฌ์ฉ์ ๊ฒฝํ๊ณผ ๋ณด์์ ์ง์ ์ ์ธ ์ํฅ์ ๋ฏธ์น๋ ์ค์ํ ์ฃผ์ ์
๋๋ค.
์ธ์
๊ด๋ฆฌ ๊ฐ์
์ธ์
์คํ ์ด Redis, PostgreSQL ํ์ฅ ๊ฐ๋ฅํ ์ ์ฅ์
์ธ์
๋ง๋ฃ ์๋ ๋ง๋ฃ ์ฌ๋ผ์ด๋ฉ ๋ง๋ฃ
ํ ํฐ ๊ฐฑ์ Refresh Token Access Token ์ฌ๋ฐ๊ธ
๋ก๊ทธ์์ ์ธ์
๋ฌดํจํ ํ ํฐ ๋ธ๋๋ฆฌ์คํธ
์ธ์
๊ธฐ๋ฐ ์ธ์ฆ
์ธ์
์ ๋์ ์๋ฆฌ
์ธ์
๊ธฐ๋ฐ ์ธ์ฆ์ ์๋ฒ๊ฐ ์ฌ์ฉ์์ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์๋ฒ ์ธก์ ์ ์ฅ ํ๋ ๋ฐฉ์์
๋๋ค. ๋์ ๋ฐฉ์์ ์ดํดํ๋ฉด ๋ณด์๊ณผ ํ์ฅ์ฑ ๋ฌธ์ ๋ฅผ ๋ ์ ๋ค๋ฃฐ ์ ์์ต๋๋ค.
์ธ์
์ธ์ฆ ํ๋ฆ :
์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธํ๋ฉด ์๋ฒ๊ฐ ์ธ์
ID๋ฅผ ์์ฑ
์ธ์
๋ฐ์ดํฐ(์ฌ์ฉ์ ID ๋ฑ)๋ฅผ ์ธ์
์คํ ์ด์ ์ ์ฅ
์ธ์
ID๋ฅผ ์ฟ ํค๋ก ํด๋ผ์ด์ธํธ์ ์ ์ก
์ดํ ์์ฒญ๋ง๋ค ์ฟ ํค์ ์ธ์
ID๋ก ์ฌ์ฉ์ ์ธ์ฆ
์ฅ์ :
์๋ฒ๊ฐ ์ธ์
์ ์์ ํ ์ ์ด (์ธ์ ๋ ๋ฌดํจํ ๊ฐ๋ฅ)
๋ฏผ๊ฐํ ์ ๋ณด๊ฐ ํด๋ผ์ด์ธํธ์ ๋
ธ์ถ๋์ง ์์
๊ตฌํ์ด ๊ฐ๋จํ๊ณ ๊ฒ์ฆ๋ ๋ฐฉ์
๋จ์ :
์๋ฒ์ ์ํ๋ฅผ ์ ์ฅํ๋ฏ๋ก ํ์ฅ์ฑ ๊ณ ๋ ค ํ์
์ธ์
์คํ ์ด๊ฐ ๋จ์ผ ์คํจ ์ง์ (SPOF)์ด ๋ ์ ์์
์ธ์
์คํ ์ด ์ค์
๋ฉ๋ชจ๋ฆฌ ์คํ ์ด (๊ฐ๋ฐ์ฉ) :
๋ฉ๋ชจ๋ฆฌ ์คํ ์ด๋ ๊ฐ๋ฐ ํ๊ฒฝ์์๋ง ์ฌ์ฉํด์ผ ํฉ๋๋ค. ์๋ฒ๋ฅผ ์ฌ์์ํ๋ฉด ๋ชจ๋ ์ธ์
์ด ์ฌ๋ผ์ง๊ณ , ์ฌ๋ฌ ์๋ฒ ์ธ์คํด์ค ๊ฐ์ ์ธ์
์ ๊ณต์ ํ ์ ์์ต๋๋ค.
// server.ts (๊ฐ๋ฐ ํ๊ฒฝ)
import fastify from "fastify" ;
import fastifySession from "@fastify/session" ;
import fastifyCookie from "@fastify/cookie" ;
const app = fastify ();
app . register ( fastifyCookie );
app . register ( fastifySession , {
secret: process . env . SESSION_SECRET ! ,
cookie: {
secure: false , // ๊ฐ๋ฐ ํ๊ฒฝ์์๋ HTTP ํ์ฉ
httpOnly: true ,
maxAge: 1000 * 60 * 60 * 24 , // 1์ผ
},
saveUninitialized: false ,
// store๋ฅผ ์ง์ ํ์ง ์์ผ๋ฉด ๋ฉ๋ชจ๋ฆฌ ์คํ ์ด ์ฌ์ฉ
});
๋ฉ๋ชจ๋ฆฌ ์คํ ์ด์ ๋ฌธ์ ์ :
์๋ฒ ์ฌ์์ ์ ๋ชจ๋ ์ธ์
์์ค
์ฌ๋ฌ ์๋ฒ ์ธ์คํด์ค ๊ฐ ์ธ์
๊ณต์ ๋ถ๊ฐ
๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋ ์ฆ๊ฐ๋ก ์ฑ๋ฅ ์ ํ ๊ฐ๋ฅ
ํ๋ก๋์
์์๋ ์ ๋ ์ฌ์ฉ ๊ธ์ง
Redis ์คํ ์ด (ํ๋ก๋์
) :
ํ๋ก๋์
ํ๊ฒฝ์์๋ Redis๋ฅผ ์ธ์
์คํ ์ด๋ก ์ฌ์ฉํ๋ ๊ฒ์ด ์
๊ณ ํ์ค์
๋๋ค. Redis๋ ๋น ๋ฅด๊ณ , ํ์ฅ ๊ฐ๋ฅํ๋ฉฐ, ์ฌ๋ฌ ์๋ฒ ์ธ์คํด์ค ๊ฐ ์ธ์
๊ณต์ ๋ฅผ ์ง์ํฉ๋๋ค.
npm install @fastify/redis connect-redis
# or
pnpm add @fastify/redis connect-redis
// server.ts (ํ๋ก๋์
)
import fastify from "fastify" ;
import fastifySession from "@fastify/session" ;
import fastifyCookie from "@fastify/cookie" ;
import fastifyRedis from "@fastify/redis" ;
import RedisStore from "connect-redis" ;
const app = fastify ();
// Redis ์ฐ๊ฒฐ
app . register ( fastifyRedis , {
host: process . env . REDIS_HOST || "localhost" ,
port: parseInt ( process . env . REDIS_PORT || "6379" ),
password: process . env . REDIS_PASSWORD ,
// ์ฐ๊ฒฐ ํ ์ค์
lazyConnect: false ,
enableReadyCheck: true ,
});
app . register ( fastifyCookie );
// Redis๋ฅผ ์ธ์
์คํ ์ด๋ก ์ฌ์ฉ
app . register ( fastifySession , {
secret: process . env . SESSION_SECRET ! ,
store: new RedisStore ({
client: app . redis , // Fastify Redis ํด๋ผ์ด์ธํธ ์ฌ์ฉ
prefix: "sess:" , // ์ธ์
ํค ์ ๋์ฌ
ttl: 60 * 60 * 24 * 7 , // 7์ผ (์ด ๋จ์)
}),
cookie: {
secure: true , // HTTPS ํ์
httpOnly: true , // XSS ๋ฐฉ์ง
sameSite: "strict" , // CSRF ๋ฐฉ์ง
maxAge: 1000 * 60 * 60 * 24 * 7 , // 7์ผ
},
saveUninitialized: false ,
resave: false ,
});
Redis ์ค์ ์ค๋ช
:
prefix: ์ธ์
ํค์ ์ ๋์ฌ๋ฅผ ๋ถ์ฌ ๋ค๋ฅธ ๋ฐ์ดํฐ์ ๊ตฌ๋ถ
ttl: Time To Live, ์ธ์
์๋ ๋ง๋ฃ ์๊ฐ (์ด ๋จ์)
client: Redis ํด๋ผ์ด์ธํธ ์ธ์คํด์ค
Redis๋ ํด๋ฌ์คํฐ ๋ชจ๋๋ฅผ ์ง์ํ์ฌ ๊ณ ๊ฐ์ฉ์ฑ(HA)์ ๊ตฌํํ ์ ์์ต๋๋ค. AWS ElastiCache, Azure Cache for Redis ๋ฑ์ ๊ด๋ฆฌํ ์๋น์ค๋ฅผ ์ฌ์ฉํ๋ฉด ์ด์์ด ๋ ํธ๋ฆฌํฉ๋๋ค.
PostgreSQL ์คํ ์ด (๋์) :
์ด๋ฏธ PostgreSQL์ ์ฌ์ฉ ์ค์ด๋ผ๋ฉด ๋ณ๋์ Redis ์์ด PostgreSQL์ ์ธ์
์คํ ์ด๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค. ๋จ, ์ฑ๋ฅ์ Redis๋ณด๋ค ๋ฎ์ต๋๋ค.
npm install connect-pg-simple
# or
pnpm add connect-pg-simple
import session from "express-session" ;
import connectPgSimple from "connect-pg-simple" ;
import pg from "pg" ;
const PgStore = connectPgSimple ( session );
const pgPool = new pg . Pool ({
host: process . env . DB_HOST ,
port: parseInt ( process . env . DB_PORT || "5432" ),
user: process . env . DB_USER ,
password: process . env . DB_PASSWORD ,
database: process . env . DB_NAME ,
});
app . register ( fastifySession , {
secret: process . env . SESSION_SECRET ! ,
store: new PgStore ({
pool: pgPool ,
tableName: "sessions" , // ์ธ์
ํ
์ด๋ธ๋ช
}),
cookie: {
secure: true ,
httpOnly: true ,
maxAge: 1000 * 60 * 60 * 24 * 7 ,
},
});
์ธ์
๋ง๋ฃ ์ ๋ต
๊ณ ์ ๋ง๋ฃ (Fixed Expiration) :
์ธ์
์ด ์์ฑ๋ ์์ ๋ถํฐ ์ ํด์ง ์๊ฐ์ด ์ง๋๋ฉด ๋ฌด์กฐ๊ฑด ๋ง๋ฃ๋ฉ๋๋ค. ๋ณด์์ฑ์ ๋์ง๋ง ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข์ง ์์ ์ ์์ต๋๋ค.
app . register ( fastifySession , {
secret: process . env . SESSION_SECRET ! ,
cookie: {
maxAge: 1000 * 60 * 60 * 2 , // 2์๊ฐ ํ ๋ฌด์กฐ๊ฑด ๋ง๋ฃ
},
rolling: false , // ์ฌ๋ผ์ด๋ฉ ๋ง๋ฃ ๋นํ์ฑํ
});
์ฌ๋ผ์ด๋ฉ ๋ง๋ฃ (Sliding Expiration) :
์ฌ์ฉ์๊ฐ ํ๋ํ ๋๋ง๋ค ์ธ์
๋ง๋ฃ ์๊ฐ์ด ๊ฐฑ์ ๋ฉ๋๋ค. โRemember Meโ ๊ธฐ๋ฅ์ ์ ํฉํ๋ฉฐ, ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข์ต๋๋ค.
app . register ( fastifySession , {
secret: process . env . SESSION_SECRET ! ,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7 , // 7์ผ
},
rolling: true , // ๋งค ์์ฒญ๋ง๋ค ๋ง๋ฃ ์๊ฐ ๊ฐฑ์
resave: false , // ์ธ์
์ด ๋ณ๊ฒฝ๋์ง ์์๋ ์ ์ฅ
});
ํ์ด๋ธ๋ฆฌ๋ ์ ๋ต :
์ด๊ธฐ์๋ ์งง์ ๋ง๋ฃ ์๊ฐ์ ์ค์ ํ๊ณ , โRemember Meโ๋ฅผ ์ ํํ๋ฉด ๊ธด ๋ง๋ฃ ์๊ฐ์ ์ ์ฉํฉ๋๋ค.
class AuthModel extends BaseModelClass {
@ api ({ httpMethod: "POST" })
async login ( params : {
email : string ;
password : string ;
rememberMe ?: boolean ;
}) : Promise <{ user : User }> {
const { email , password , rememberMe } = params ;
const context = Sonamu . getContext ();
// ์ธ์ฆ ๋ก์ง...
const user = await this . authenticateUser ( email , password );
// Remember Me์ ๋ฐ๋ผ ์ธ์
๋ง๋ฃ ์๊ฐ ์กฐ์
if ( rememberMe ) {
context . request . session . cookie . maxAge = 1000 * 60 * 60 * 24 * 30 ; // 30์ผ
} else {
context . request . session . cookie . maxAge = 1000 * 60 * 60 * 2 ; // 2์๊ฐ
}
// ์ธ์
์ ๋ก๊ทธ์ธ ์ ๋ณด ์ ์ฅ
await this . loginToSession ( user );
return { user };
}
}
์ธ์
๋ฐ์ดํฐ ๊ด๋ฆฌ
์ธ์
์๋ ์ต์ํ์ ์ ๋ณด๋ง ์ ์ฅํด์ผ ํฉ๋๋ค. ์ธ์
ํฌ๊ธฐ๊ฐ ์ปค์ง๋ฉด ์ฑ๋ฅ์ด ์ ํ๋๊ณ , ์คํ ๋ฆฌ์ง ๋น์ฉ์ด ์ฆ๊ฐํฉ๋๋ค.
// โ
์ข์ ์: ์ต์ ์ ๋ณด๋ง ์ ์ฅ
context . request . session . userId = user . id ;
context . request . session . role = user . role ;
// โ ๋์ ์: ๋๋ฌด ๋ง์ ์ ๋ณด ์ ์ฅ
context . request . session . user = {
id: user . id ,
email: user . email ,
username: user . username ,
bio: user . bio ,
profileImage: user . profileImage ,
preferences: user . preferences ,
// ... ์๋ง์ ํ๋
};
์ธ์
๋ฐ์ดํฐ ์ ๊ทผ :
class UserModel extends BaseModelClass {
@ api ({ httpMethod: "GET" })
async getProfile () : Promise <{ user : User }> {
const context = Sonamu . getContext ();
// ์ธ์
์์ ์ฌ์ฉ์ ID ๊ฐ์ ธ์ค๊ธฐ
const userId = context . request . session . userId ;
if ( ! userId ) {
throw new Error ( "Not authenticated" );
}
// ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ต์ ์ ๋ณด ์กฐํ
const rdb = this . getPuri ( "r" );
const user = await rdb
. table ( "users" )
. where ( "id" , userId )
. first ();
return { user };
}
}
JWT ํ ํฐ ๊ด๋ฆฌ
JWT์ ๋์ ์๋ฆฌ
JWT(JSON Web Token)๋ ์ํ๋ฅผ ์ ์ฅํ์ง ์๋(stateless) ์ธ์ฆ ๋ฐฉ์์
๋๋ค. ์๋ฒ๊ฐ ํ ํฐ์ ์์ฑํ ํ์๋ ํ ํฐ ์์ฒด์ ๋ชจ๋ ์ ๋ณด๊ฐ ํฌํจ๋์ด ์์ด, ์๋ฒ๊ฐ ๋ณ๋๋ก ์ ์ฅํ ํ์๊ฐ ์์ต๋๋ค.
JWT ๊ตฌ์กฐ :
Header : ํ ํฐ ํ์
๊ณผ ์๊ณ ๋ฆฌ์ฆ (์: {"alg": "HS256", "typ": "JWT"})
Payload : ์ฌ์ฉ์ ์ ๋ณด์ ํด๋ ์ (์: {"userId": 123, "role": "user"})
Signature : ํ ํฐ์ ๋ฌด๊ฒฐ์ฑ์ ๊ฒ์ฆํ๋ ์๋ช
JWT์ ์ฅ์ :
์๋ฒ๊ฐ ์ํ๋ฅผ ์ ์ฅํ์ง ์์ ํ์ฅ์ฑ์ด ์ข์
๋ง์ดํฌ๋ก์๋น์ค ํ๊ฒฝ์ ์ ํฉ
ํ ํฐ ์์ฒด์ ์ ๋ณด๊ฐ ํฌํจ๋์ด ์์ด ๋ณ๋ ์กฐํ ๋ถํ์
JWT์ ๋จ์ :
ํ ํฐ์ ์๋ฒ์์ ๊ฐ์ ๋ก ๋ฌดํจํํ๊ธฐ ์ด๋ ค์
ํ ํฐ ํฌ๊ธฐ๊ฐ ์ฟ ํค๋ณด๋ค ํผ
ํ ํฐ์ด ํ์ทจ๋๋ฉด ๋ง๋ฃ ์ ๊น์ง ์ฌ์ฉ ๊ฐ๋ฅ
Access Token๊ณผ Refresh Token
Access Token ์ API ์์ฒญ์ ์ฌ์ฉ๋๋ ์งง์ ์๋ช
์ ํ ํฐ ์
๋๋ค. ๋ณดํต 15๋ถ~1์๊ฐ ์ ๋์ ์งง์ ์ ํจ๊ธฐ๊ฐ์ ๊ฐ์ง๋๋ค. ํ์ทจ๋๋๋ผ๋ ํผํด๋ฅผ ์ต์ํํ ์ ์์ต๋๋ค.
Refresh Token ์ Access Token์ ์ฌ๋ฐ๊ธ๋ฐ๊ธฐ ์ํ ๊ธด ์๋ช
์ ํ ํฐ ์
๋๋ค. ๋ณดํต 7์ผ~30์ผ ์ ๋์ ์ ํจ๊ธฐ๊ฐ์ ๊ฐ์ง๋ฉฐ, ์๋ฒ์ ์ ์ฅํ์ฌ ๊ด๋ฆฌํฉ๋๋ค.
์ ๋ ๊ฐ์ ํ ํฐ์ ์ฌ์ฉํ๋์?
๋ง์ฝ Access Token์ ์ ํจ๊ธฐ๊ฐ์ 30์ผ๋ก ์ค์ ํ๋ฉด:
ํ ํฐ์ด ํ์ทจ๋๋ฉด 30์ผ ๋์ ์
์ฉ๋ ์ ์์
์ฌ์ฉ์ ๊ถํ ๋ณ๊ฒฝ์ด ์ฆ์ ๋ฐ์๋์ง ์์
Access Token์ ์งง๊ฒ ์ค์ ํ๊ณ Refresh Token์ผ๋ก ๊ฐฑ์ ํ๋ฉด:
ํ์ทจ๋ Access Token์ ์งง์ ์๊ฐ๋ง ์ ํจ
๊ถํ ๋ณ๊ฒฝ์ ๋น ๋ฅด๊ฒ ๋ฐ์ ๊ฐ๋ฅ
Refresh Token์ ์๋ฒ์ ์ ์ฅํ์ฌ ๋ฌดํจํ ๊ฐ๋ฅ
// auth/jwt.ts
import jwt from "jsonwebtoken" ;
const ACCESS_TOKEN_SECRET = process . env . ACCESS_TOKEN_SECRET ! ;
const REFRESH_TOKEN_SECRET = process . env . REFRESH_TOKEN_SECRET ! ;
/**
* Access Token ์์ฑ (์งง์ ์๋ช
)
*/
export function generateAccessToken ( user : User ) : string {
return jwt . sign (
{
userId: user . id ,
email: user . email ,
role: user . role ,
},
ACCESS_TOKEN_SECRET ,
{
expiresIn: "15m" , // 15๋ถ
}
);
}
/**
* Refresh Token ์์ฑ (๊ธด ์๋ช
)
*/
export function generateRefreshToken ( user : User ) : string {
return jwt . sign (
{
userId: user . id ,
// Refresh Token์๋ ์ต์ ์ ๋ณด๋ง
},
REFRESH_TOKEN_SECRET ,
{
expiresIn: "7d" , // 7์ผ
}
);
}
/**
* Access Token ๊ฒ์ฆ
*/
export function verifyAccessToken ( token : string ) {
try {
return jwt . verify ( token , ACCESS_TOKEN_SECRET );
} catch ( error ) {
return null ;
}
}
/**
* Refresh Token ๊ฒ์ฆ
*/
export function verifyRefreshToken ( token : string ) {
try {
return jwt . verify ( token , REFRESH_TOKEN_SECRET );
} catch ( error ) {
return null ;
}
}
๋ณด์ ์ฃผ์์ฌํญ :
Access Token๊ณผ Refresh Token์ Secret์ ๋ฐ๋์ ๋ค๋ฅด๊ฒ ์ค์
Refresh Token์ ์๋ฒ์ ์ ์ฅํ์ฌ ๋ฌดํจํ ๊ฐ๋ฅํ๊ฒ ๋ง๋ค๊ธฐ
XSS ๊ณต๊ฒฉ์ ๋ง๊ธฐ ์ํด ํ ํฐ์ localStorage ๋์ httpOnly ์ฟ ํค์ ์ ์ฅ ๊ถ์ฅ
Refresh Token ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง
CREATE TABLE refresh_tokens (
id SERIAL PRIMARY KEY ,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ,
token TEXT NOT NULL UNIQUE ,
expires_at TIMESTAMP NOT NULL ,
created_at TIMESTAMP DEFAULT NOW (),
INDEX idx_user_id (user_id),
INDEX idx_token (token),
INDEX idx_expires_at (expires_at)
);
ํ ํฐ ๊ฐฑ์ ๊ตฌํ
class AuthModel extends BaseModelClass {
/**
* Access Token ๊ฐฑ์
*
* Refresh Token์ ๋ฐ์ ์๋ก์ด Access Token์ ๋ฐ๊ธํฉ๋๋ค.
* Refresh Token์ ์ ํจ์ฑ๊ณผ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ์ฅ ์ฌ๋ถ๋ฅผ ๋ชจ๋ ํ์ธํฉ๋๋ค.
*/
@ api ({ httpMethod: "POST" })
async refreshToken ( params : {
refreshToken : string ;
}) : Promise <{
accessToken : string ;
refreshToken ?: string ; // Refresh Token Rotation ์
}> {
const { refreshToken } = params ;
// 1. Refresh Token ๊ฒ์ฆ
const payload = verifyRefreshToken ( refreshToken );
if ( ! payload || typeof payload === "string" ) {
throw new Error ( "Invalid refresh token" );
}
// 2. ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ Refresh Token ํ์ธ
const rdb = this . getPuri ( "r" );
const storedToken = await rdb
. table ( "refresh_tokens" )
. where ( "token" , refreshToken )
. where ( "user_id" , payload . userId )
. where ( "expires_at" , ">" , new Date ())
. first ();
if ( ! storedToken ) {
throw new Error ( "Refresh token not found or expired" );
}
// 3. ์ฌ์ฉ์ ์ ๋ณด ์กฐํ
const user = await rdb
. table ( "users" )
. where ( "id" , payload . userId )
. first ();
if ( ! user ) {
throw new Error ( "User not found" );
}
// 4. ์ Access Token ์์ฑ
const accessToken = generateAccessToken ( user );
// 5. Refresh Token Rotation (์ ํ์ฌํญ)
// ๋ณด์ ๊ฐํ๋ฅผ ์ํด Refresh Token๋ ํจ๊ป ๊ฐฑ์
const newRefreshToken = generateRefreshToken ( user );
const wdb = this . getPuri ( "w" );
// ๊ธฐ์กด Refresh Token ์ญ์
await wdb
. table ( "refresh_tokens" )
. where ( "token" , refreshToken )
. delete ();
// ์ Refresh Token ์ ์ฅ
await wdb . table ( "refresh_tokens" ). insert ({
user_id: user . id ,
token: newRefreshToken ,
expires_at: new Date ( Date . now () + 7 * 24 * 60 * 60 * 1000 ), // 7์ผ
});
return {
accessToken ,
refreshToken: newRefreshToken ,
};
}
}
Refresh Token Rotation ์ Refresh Token์ ํ ๋ฒ ์ฌ์ฉํ๋ฉด ์๋ก์ด Refresh Token์ ๋ฐ๊ธํ์ฌ ๋ณด์์ ๊ฐํํ๋ ๊ธฐ๋ฒ์
๋๋ค. ํ ํฐ์ด ํ์ทจ๋๋๋ผ๋ ํ์ทจ์์ ์ ์ ์ฌ์ฉ์ ์ค ํ ์ชฝ๋ง ์ ํ ํฐ์ ๋ฐ์ ์ ์์ผ๋ฏ๋ก, ํ์ทจ๋ฅผ ๋น ๋ฅด๊ฒ ๊ฐ์งํ ์ ์์ต๋๋ค.
ํ ํฐ ๋ฌดํจํ (Logout)
JWT๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋ง๋ฃ ์ ๊น์ง ์ ํจํ์ง๋ง, ๋ธ๋๋ฆฌ์คํธ๋ฅผ ์ฌ์ฉํ์ฌ ๊ฐ์ ๋ก ๋ฌดํจํํ ์ ์์ต๋๋ค.
class AuthModel extends BaseModelClass {
/**
* ๋ก๊ทธ์์
*
* Refresh Token์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ญ์ ํ๊ณ ,
* Access Token์ ๋ธ๋๋ฆฌ์คํธ์ ์ถ๊ฐํฉ๋๋ค.
*/
@ api ({ httpMethod: "POST" })
async logout () : Promise <{ success : boolean }> {
const context = Sonamu . getContext ();
if ( ! context . user ) {
throw new Error ( "Not authenticated" );
}
const wdb = this . getPuri ( "w" );
// 1. ๋ชจ๋ Refresh Token ์ญ์
await wdb
. table ( "refresh_tokens" )
. where ( "user_id" , context . user . id )
. delete ();
// 2. ํ์ฌ Access Token์ ๋ธ๋๋ฆฌ์คํธ์ ์ถ๊ฐ
const authHeader = context . request . headers . authorization ;
if ( authHeader && authHeader . startsWith ( "Bearer " )) {
const token = authHeader . substring ( 7 );
// Access Token์ ๋จ์ ์ ํจ์๊ฐ ๊ณ์ฐ
const decoded = jwt . decode ( token ) as any ;
const expiresIn = decoded . exp - Math . floor ( Date . now () / 1000 );
// Redis์ ๋ธ๋๋ฆฌ์คํธ ์ถ๊ฐ (TTL์ ํ ํฐ ๋จ์ ์๊ฐ)
const redis = getRedisClient ();
await redis . setex ( `blacklist: ${ token } ` , expiresIn , "1" );
}
return { success: true };
}
/**
* ๋ชจ๋ ์ธ์
๋ก๊ทธ์์ (๋ค๋ฅธ ๊ธฐ๊ธฐ ํฌํจ)
*/
@ api ({ httpMethod: "POST" })
async logoutAll () : Promise <{ success : boolean }> {
const context = Sonamu . getContext ();
if ( ! context . user ) {
throw new Error ( "Not authenticated" );
}
const wdb = this . getPuri ( "w" );
// ๋ชจ๋ Refresh Token ์ญ์
await wdb
. table ( "refresh_tokens" )
. where ( "user_id" , context . user . id )
. delete ();
return { success: true };
}
}
ํ ํฐ ๋ธ๋๋ฆฌ์คํธ ๋ฏธ๋ค์จ์ด
// auth/jwt.middleware.ts
import type { FastifyRequest , FastifyReply } from "fastify" ;
export async function checkTokenBlacklist (
request : FastifyRequest ,
reply : FastifyReply
) {
const authHeader = request . headers . authorization ;
if ( ! authHeader || ! authHeader . startsWith ( "Bearer " )) {
return ;
}
const token = authHeader . substring ( 7 );
// Redis์์ ๋ธ๋๋ฆฌ์คํธ ํ์ธ
const redis = getRedisClient ();
const isBlacklisted = await redis . exists ( `blacklist: ${ token } ` );
if ( isBlacklisted ) {
return reply . status ( 401 ). send ({
error: "Token has been revoked" ,
});
}
}
์ค์ ํจํด
๋์ ์ ์ ์ ํ
ํ ๊ณ์ ์ผ๋ก ์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ๋์ ์ ์์ ์ ํํ๋ ๋ฐฉ๋ฒ์
๋๋ค.
class AuthModel extends BaseModelClass {
@ api ({ httpMethod: "POST" })
async login ( params : {
email : string ;
password : string ;
}) : Promise <{ accessToken : string }> {
const { email , password } = params ;
// ์ธ์ฆ...
const user = await this . authenticateUser ( email , password );
const wdb = this . getPuri ( "w" );
// ๊ธฐ์กด ์ธ์
๋ชจ๋ ์ญ์ (๊ฐ์ ๋ก๊ทธ์์)
await wdb
. table ( "refresh_tokens" )
. where ( "user_id" , user . id )
. delete ();
// ์ ํ ํฐ ๋ฐ๊ธ
const accessToken = generateAccessToken ( user );
const refreshToken = generateRefreshToken ( user );
await wdb . table ( "refresh_tokens" ). insert ({
user_id: user . id ,
token: refreshToken ,
expires_at: new Date ( Date . now () + 7 * 24 * 60 * 60 * 1000 ),
});
return { accessToken };
}
}
๋ง๋ฃ๋ ์ธ์
์ ๋ฆฌ
์ฃผ๊ธฐ์ ์ผ๋ก ๋ง๋ฃ๋ Refresh Token์ ์ญ์ ํ์ฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํฌ๊ธฐ๋ฅผ ๊ด๋ฆฌํฉ๋๋ค.
// tasks/cleanup-tokens.ts
import { task } from "sonamu" ;
/**
* ๋งค์ผ ์์ ์ ๋ง๋ฃ๋ Refresh Token ์ญ์
*/
@ task ({ cron: "0 0 * * *" })
export async function cleanupExpiredTokens () {
const db = getDatabase ();
const result = await db
. table ( "refresh_tokens" )
. where ( "expires_at" , "<" , new Date ())
. delete ();
console . log ( `Deleted ${ result } expired refresh tokens` );
}
์ฃผ์์ฌํญ
์ธ์
/ํ ํฐ ๊ด๋ฆฌ ์ฃผ์์ฌํญ :
์ธ์
Secret๊ณผ JWT Secret์ ํ๊ฒฝ๋ณ์๋ก ๊ด๋ฆฌ
ํ๋ก๋์
์์๋ Redis ๋ฑ ์ธ๋ถ ์ธ์
์คํ ์ด ํ์
Refresh Token์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅํ์ฌ ๋ฌดํจํ ๊ฐ๋ฅํ๊ฒ
Access Token์ ์งง๊ฒ, Refresh Token์ ๊ธธ๊ฒ ์ค์
HTTPS ์ฌ์ฉ ์์๋ง secure: true ์ค์
XSS ๋ฐฉ์ง๋ฅผ ์ํด httpOnly: true ํ์
๋ธ๋๋ฆฌ์คํธ๋ Redis์ TTL ๊ธฐ๋ฅ ํ์ฉ
๋ค์ ๋จ๊ณ