728x90
반응형
계기
- 이전에 만들었던 프로젝트들을 리팩토링 중, 검증 방법이 헤더의 Authorization 으로 토큰 검증을 하게 해 뒀던 걸 발견했다.
- 뭐 잘못된 방법은 아니지만, 쿠키를 활용해서 검증하는 방법으로 변경하였다.
개요
- 로그인 요청을 받았을 때, 이메일/패스워드 정보를 검증 후 유저 정보를 Redis 서버에서 체크한다.
- Redis 서버에 해당 정보가 조회될 경우, 새로운 환경에서 로그인 요청을 한 것으로 판단한다.
- 이전 로그인은 종료시키기 위해, 해당 정보를 삭제한다.
- 조회된 정보가 없다면 이전 로그인은 만료된 것이므로 무시한다.
- Redis 서버에 해당 정보가 조회될 경우, 새로운 환경에서 로그인 요청을 한 것으로 판단한다.
- 이후 새로운 accessToken과 refreshToken을 생성한다.
- refreshToken을 상위 키로, 하위 키로는 유저 id 로 삼아 로그인 정보를 세팅한다.
- 키: refresh:{refreshToken}:{userId}
- refreshToken을 상위 키로, 하위 키로는 유저 id 로 삼아 로그인 정보를 세팅한다.
- 해당 키들을 응답 쿠키에 각각 도메인, 만료시간, 토큰, secure, httpOnly 를 세팅한다.
- accessToken의 만료 시간은 한시간, refreshToken의 만료 시간은 일주일로 세팅한다.
- 이후 로그인 및 유저 정보가 필요한 요청은 미들웨어에서 토큰 검증 및 데이터 추출하여 사용한다.
- 만료시간이 끝난 경우, 토큰 갱신 요청을 날려서 새로운 access token을 발급 받는다.
코드 구현
- 백엔드는 golang, 프론트는 nextjs app router를 사용하였다.
백엔드
- 주요 로직은 주석처리, 토큰 세팅 및 쿠키 세팅하는 로직만 유지
// 컨트롤러가 아닌, 비즈니스 로직을 담은 서비스
func LoginService(request LoginRequest, response http.ResponseWriter, request *http.Request) {
/*
DB 에서 유저 정보 조회 및 패스워드 매칭
*/
// Access Token 생성
token, tokenErr := auth.CreateAccessToken(userData.UserId, uuid.String(), userData.Email, fmt.Sprintf("%d", userData.UserStatus), fmt.Sprintf("%d", userData.IsAdmin), time.Hour)
if tokenErr != nil {
log.Printf("[LOGIN] Create Token Error: %v", tokenErr)
return LoginResponse{
Status: http.StatusInternalServerError,
Code: "ULG09",
Message: "Create JWT Error",
}
}
// Refresh Token 생성
refreshToken, refreshTokenErr := auth.CreateRefreshToken("",, time.Hour*24*7)
if refreshTokenErr != nil {
log.Printf("[LOGIN] Create Refresh Token Error: %v", refreshTokenErr)
return LoginResponse{
Status: http.StatusInternalServerError,
Code: "ULG09",
Message: "Create JWT Error",
}
}
// Redis 서버에서 유저 정보 조회 - 이전 로그인 만료 여부 체크
userDataFromRedis, getRedisErr := GetRefreshUserInfoFromRedis(refreshToken, userData.UserId)
if getRedisErr != nil {
return LoginResponse{
Status: http.StatusNotFound,
Code: "ULG08",
Message: "Get User Data from redis Error",
}
}
// 유저 정보가 조회되었을 시
if (userDataFromRedis != LoginUserInfo{}) {
// 조회된 Redis 정보 삭제
delerr := DeleteAlreadySetToken(refreshToken, userData.UserId)
if delerr != nil {
return LoginResponse{
Status: http.StatusInternalServerError,
Code: "ULG08",
Message: "Delete Already Set Data from redis Error",
}
}
}
// Redis 서버에 새로운 로그인 정보 세팅
setErr := SetRefreshToken(refreshToken, userData.Email, fmt.Sprintf("%d", userData.UserStatus), userData.UserId, userData.UserName, fmt.Sprintf("%d", userData.IsAdmin))
if setErr != nil {
return LoginResponse{
Status: http.StatusInternalServerError,
Code: "ULG10",
Message: "Set JWT Error",
}
}
accessTokenCookie := http.Cookie{
Name: "accessToken",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteNoneMode,
}
refreshTokenCookie := http.Cookie{
Name: "refreshToken",
Value: refreshToken,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteNoneMode,
}
http.SetCookie(response, &accessTokenCookie)
http.SetCookie(response, &refreshTokenCookie)
return LoginResponse{
Status: http.StatusOK,
Code: "0000",
AccessToken: token,
RefreshToken: refreshToken,
Message: "Login Success",
}
프론트
- App Router의 Server Side Component를 이용해서 쿠키 및 토큰을 관리한다.
- 로그인 응답으로 토큰을 받고 서버 사이드 쿠키에 세팅한다.
export async function POST(request: NextRequest) {
// const cookieStore = cookies();
// 로그인 데이터 확인 - 요청 조건 검증
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6).max(16),
});
try {
const _loginData = await request.json();
const loginData = await loginSchema.parseAsync(_loginData);
const loginResponse = await login({ ...loginData });
if (!loginResponse.ok) {
return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
}
});
const response = <LoginResponse>(await loginResponse.json());
if (response.status !== 200) {
return NextResponse.json({ message: 'Login Failed' }, { status: 302 });
}
// 브라우저에 리턴할 쿠키 설정
const ONE_HOUR = 60 * 60 * 1000;
const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;
const res = NextResponse.json({ message: 'Login Success' }, { status: 200 });
// 쿠키 설정
res.cookies.set('accessToken', response.accessToken, {
secure: true, // 프로덕션 환경에서는 true로 설정
httpOnly: true,
domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
expires: new Date(Date.now() + ONE_HOUR),
});
res.cookies.set('refreshToken', response.refreshToken, {
secure: true,
httpOnly: true,
domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
expires: new Date(Date.now() + ONE_WEEK),
});
return res;
} catch (error) {
console.log(error);
return NextResponse.json({ message: 'server error' }, { status: 500 });
}
}
- 서버 사이드 컴포넌트. 받은 쿠키들을 이용해 이후 요청을 한다.
- 만료시 토큰 리프레시를 한다.
export async function POST(request: NextRequest) {
try {
let tokenCookie = request.cookies.get("accessToken");
let refreshToken = request.cookies.get("refreshToken");
let cookie: string;
let refreshCookie: string;
if (!tokenCookie) {
/**
refresh token 갱신 요청 로직
*/
const ONE_HOUR = 60 * 60 * 1000;
const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;
cookieStore.set('accessToken', response.accessToken, {
secure: true,
httpOnly: true,
path:"/",
// domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
expires: new Date(Date.now() + ONE_HOUR),
});
cookieStore.set('refreshToken', response.refreshToken, {
secure: true,
httpOnly: true,
path:"/",
// domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
expires: new Date(Date.now() + ONE_WEEK),
});
cookie = response.accessToken;
refreshCookie = response.refreshToken;
} else {
cookie = tokenCookie.value;
refreshCookie = refreshToken.value;
// 기술통계 요청 데이터 확인
const exampleScheme = z.object({
// 요청 검증 필드
});
const _exampleData = await request.json();
const exampleData = await exampleScheme.parseAsync(_exampleData);
const exampleResponse = await example({ ...exampleData }, cookie, refreshCookie);
if (!exampleResponse.ok) {
return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
}
const dataResponse = <ExampleResponse>await exampleResponse.json();
const response = NextResponse.json(dataResponse);
return response;
}
} catch (error) {
console.log(error);
return NextResponse.json({ message: 'server error' }, { status: 500 });
}
}
- Middleware를 적용하여, 쿠키가 없을 시 로그인 페이지로 자동 리디렉션 시킨다.
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from "next/server";
// 인증 없이 방문 불가 페이지
const PROTECTED_ROUTE: string[] = [
// 페이지 리스트들
];
export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
export const authMiddleware: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, event: NextFetchEvent) => {
// URL 에서 언어 값 분리
const [ , , ...segments ] = request.nextUrl.pathname.split('/');
const cookies = request.cookies.get("accessToken");
// 체크사항
// 1. 로그인 유무
// 2. 인증 없이 방문 불가 페이지
const isProtecteRoute = PROTECTED_ROUTE.includes(request.nextUrl.pathname);
if (!cookies && isProtecteRoute) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (cookies && isProtecteRoute) {
if (cookies.value === "") {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// 인증 성공, 통과
return next(request, event);
};
};
728x90
반응형
'기록 > 사이드 프로젝트' 카테고리의 다른 글
[NEXTJS] 사이드 컴포넌트 쿠키 처리 (0) | 2025.01.22 |
---|---|
[OAUTH] 구글 OAuth (0) | 2025.01.02 |