인증/인가

소개

주로 사용하는 인증/인가 코드 정리
이 문서는 Spring Boot에 대한 코드도 같이 제공한다.

NextJS 설정

JwtManager.ts

  • jose 라이브러리 필요
bash
npm install jose yarn add jose
typescript
// lib/JwtManager.ts import { cookies } from "next/headers"; import { jwtVerify, decodeJwt, JWTPayload } from "jose"; export interface CustomJWTPayload extends JWTPayload { username?: string; role?: string; } export const JwtTokenManager = { ACCESS_TOKEN_NAME: "accessToken", /** * 토큰 가져오기 (Cookie 우선, 필요시 로직 확장) */ async getAccessToken(): Promise<string | undefined> { const cookieStore = await cookies(); return cookieStore.get(this.ACCESS_TOKEN_NAME)?.value; }, /** * 토큰 저장 (Body로 받았을 때 호출, Set-Cookie로 왔다면 브라우저가 자동 처리) * Server Action이나 Route Handler에서 사용합니다. */ async setToken(token: string) { const cookieStore = await cookies(); // 토큰에서 만료 시간을 읽어와 쿠키 만료 시간과 동기화 const payload = this.decode(token); const maxAge = payload?.exp ? payload.exp - Math.floor(Date.now() / 1000) : 3600; cookieStore.set(this.ACCESS_TOKEN_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: maxAge > 0 ? maxAge : 0, }); }, /** * 단순 디코딩 (Claim 추출용, 서명 검증 X) */ decode(token: string): CustomJWTPayload | null { try { return decodeJwt(token) as CustomJWTPayload; } catch { return null; } }, /** * 만료 여부 확인 */ isExpired(token: string): boolean { const payload = this.decode(token); if (!payload || !payload.exp) return true; // 현재 시간(초)과 비교 return payload.exp < Math.floor(Date.now() / 1000); }, /** * 완전한 검증 (서명 확인 포함) * 보안이 중요한 API 호출 전에 사용합니다. */ async verify(token: string) { try { const secret = new TextEncoder().encode(process.env.JWT_SECRET); const { payload } = await jwtVerify(token, secret); return payload as CustomJWTPayload; } catch (error) { return null; // 만료되었거나 변조된 경우 } }, /** * 로그아웃 */ async clear() { const cookieStore = await cookies(); cookieStore.delete(this.ACCESS_TOKEN_NAME); }, };

proxy.ts (middleware.ts)

typescript
// 인증이 필요 없는 공개 경로들 const publicPaths = ["/login", "/register", "/api/auth"]; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const { cookies } = request; const accessToken: any = cookies.get("accessToken"); const refreshToken: any = cookies.get("refreshToken"); // 공개 경로는 통과 if (publicPaths.some((path) => pathname.startsWith(path))) { // 만약 로그인이 된 경우라면, 바로 홈화면으로 이동 if (accessToken && !JwtTokenManager.isExpired(accessToken)) { return NextResponse.redirect(new URL("/home", request.url)); } return NextResponse.next(); } // 액세스 토큰이 유효면 바로 통과 if (accessToken && !JwtTokenManager.isExpired(accessToken)) { return NextResponse.next(); } // 리프레시 토큰이 유효하면 바로 통과 if (refreshToken && !JwtTokenManager.isExpired(refreshToken)) { // 백엔드에 토큰 갱신 요청 const newTokens = await api.post("/api/auth/refresh"); if (newTokens) { // 새 토큰을 다시 쿠키에 심어줌 (다음 접속을 위해) const response = NextResponse.next(); // 동일한 도메인이면서, response으로 들어오는 Cookie 값이 // HttpOnly,Secure가 true로 들어올 경우, 아래 코드는 제외해도 된다. /** * [Cookie Setting Guide] * 1. httpOnly: 클라이언트 스크립트(XSS) 접근 방지 (보안 필수) * 2. secure: HTTPS 환경에서만 전송 (운영 필수) * 3. sameSite: CSRF 방지 및 Cross-Domain 설정에 따라 'lax' 또는 'none' 사용 * 4. domain: API와 프론트 도메인이 다를 경우 '.example.com' 형태로 설정 필요 */ response.cookies.set("accessToken", newTokens?.accessToken || "", { maxAge: 900, // httpOnly: true, // 보안을 위해 운영 환경 적용 권장 // secure: process.env.NODE_ENV === "production", // sameSite: "lax", // domain: process.env.COOKIE_DOMAIN, // 멀티 도메인 대응 시 활성화 }); return response; } } // 둘 다 없거나 유효하지 않으면 로그인 페이지로 리다이렉트 // 현재 접속 시도 중인 pathname과 searchParams를 포함하여 전달 const loginUrl = new URL("/login", request.url); const callbackUrl = pathname + request.nextUrl.search; // 쿼리 스트링까지 포함 if (pathname !== "/") { // 메인 페이지가 아닐 때만 callbackUrl 부여 (선택 사항) loginUrl.searchParams.set("redirect", callbackUrl); } return NextResponse.redirect(loginUrl); } // 미들웨어가 적용될 경로 설정 export const config = { matcher: [ /* * 다음 경로를 제외한 모든 요청에 미들웨어 적용: * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) */ "/((?!api|_next/static|_next/image|favicon.ico).*)", ], };
  • redirect 적용시 로그인 페이지에 추가될 코드
jsx
const searchParams = useSearchParams(); const callbackUrl = searchParams.get("redirect") || "로그인 후 들어갈 기본 페이지 주소"; // 로그인 성공 시 router.push(callbackUrl);

Zustand 유저 정보 저장

  • persist 를 활용하여 화면을 다시 불러와도 localStorage에 정보를 가지고 있게 한다.
typescript
// store/useUserStore.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; interface User { username?: string; email?: string; role?: string; } interface UserState { user: User | null; isLoading: boolean; isAuthenticated: boolean; // Actions setUser: (user: User | null) => void; fetchUserData: (token: string) => Promise<void>; logout: () => void; } // persist 를 활용하여 refresh를 하지 않아도 localStorage에 정보를 가지고 있게 한다. export const useUserStore = create<UserState>()( persist( (set, get) => ({ user: null, isLoading: false, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false, }), fetchUserData: async (token: string) => { const { user, isLoading } = get(); // 이미 유저 정보가 존재한다면 굳이 다시 부르지 않음 (캐시 활용) if (user || isLoading) return; set({ isLoading: true }); try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/me`, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); if (!response.ok) throw new Error("Failed to fetch user"); const userData: User = await response.json(); set({ user: userData, isAuthenticated: true, isLoading: false }); } catch (error) { console.error("Fetch user error:", error); set({ user: null, isAuthenticated: false, isLoading: false }); } }, logout: () => { set({ user: null, isAuthenticated: false, isLoading: false }); // 필요 시 여기서 쿠키 삭제를 위한 API 호출이나 로직 추가 }, }), { name: "user-storage", // localStorage에 저장될 key partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated, }), }, ), );

AuthProvider.tsx

  • 사용자 정보 자동 갱신을 위한 Provider
typescript
// providers/AuthProvider.tsx 'use client'; import { useEffect, ReactNode } from 'react'; import { useUserStore } from '@/store/useUserStore'; interface AuthProviderProps { children: ReactNode; token?: string; } export const AuthProvider = ({ children, token }: AuthProviderProps) => { const { fetchUserData, setUser } = useUserStore(); useEffect(() => { if (token) { // 토큰이 있으면 백엔드에서 유저 정보를 가져옴 fetchUserData(token); } else { // 토큰이 없으면 스토어 초기화 setUser(null); } }, [token, fetchUserData, setUser]); return <>{children}</>; };
  • app/layout.tsx 적용 예제 (전역 관리를 위해)
typescript
// app/layout.tsx import { AuthProvider } from '@/providers/AuthProvider'; import { JwtTokenManager } from '@/lib/JwtTokenManager'; export default async function RootLayout({ children }: { children: React.ReactNode }) { // 서버 사이드에서 쿠키 읽기 const token = await JwtTokenManager.getAccessToken(); return ( <html lang="ko"> <body> <AuthProvider token={token}> {children} </AuthProvider> </body> </html> ); }
  • 위 RootLayout에선 token이 있는 경우만 해당된다.
    Login 후 새로고침 없이 바로 token 값을 갱신하고 싶으면,
    로그인 처리후 router.refresh() 필수. 아래는 예시코드
typescript
const handleLogin = async () => { const res = await loginApi(); //... token 저장 로직. (ex: Set-Cookie, 또는 Response Body) if (res.ok) { // 이 명령이 호출되면 RootLayout의 `TokenManager.getAccessToken()`이 다시 실행됩니다. router.refresh(); router.push("/dashboard"); } };

Spring Boot 설정

Controller

java
@RestController @RequestMapping("/api/auth/") @RequiredArgsConstructor public class AuthController { private final AuthService authService; @PostMapping("/login") public ResponseEntity<?> login(HttpServletResponse response, @RequestBody Map<String, String> req) { authService.login(response, req); return ResponseEntity.ok("Login Success"); } @PostMapping("/refresh") public ResponseEntity<?> refreshToken(HttpServletRequest request, HttpServletResponse response) { // 쿠키에서 httpOnly인 refreshToken 추출 if(authService.refresh(request, response)) { return ResponseEntity.ok().build(); } return ResponseEntity.status(401).build(); } @PostMapping("/logout") public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) { authService.logout(request, response); return ResponseEntity.ok("Logout Success"); } }

Servicec

java
@Service @RequiredArgsConstructor public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final Long SECOND = 1000L; private final Long MINUTE = 60 * SECOND; private final Long HOUR = 60 * MINUTE; private final Long DAY = 24 * HOUR; public void login(HttpServletResponse response, Map<String, String> req) { // 유저 검증 (MariaDB) 후 토큰 생성 String accessToken = jwtTokenProvider.createToken(req.get("username"), "USER", HOUR); String refreshToken = jwtTokenProvider.createToken(req.get("username"), "USER", 7 * DAY); // (선택) Redis에 Refresh Token 저장 // redisTemplate.opsForValue().set("RT:" + req.getUsername(), refreshToken, 7, TimeUnit.DAYS); // Access Token 설정 (httpOnly: false) -> JS에서 읽을 수 있음 addCookie(response, "accessToken", accessToken, HOUR.intValue(), false); // JS 접근 가능 // Refresh Token 설정 (httpOnly: true) -> JS 접근 불가 (보안) addCookie(response, "refreshToken", refreshToken, 7 * DAY.intValue(), true); // JS 접근 가능 } public boolean refresh(HttpServletRequest request, HttpServletResponse response) { String refreshToken = getCookie(request, "refreshToken"); if (!jwtTokenProvider.validateToken(refreshToken)) { return false; } String username = jwtTokenProvider.getUsername(refreshToken); // (선택) Redis에 저장된 토큰과 대조 // String savedToken = redisTemplate.opsForValue().get("RT:" + username); // if (savedToken == null || !savedToken.equals(refreshToken)) { // return false; // } // accessToken 재발행 String accessToken = jwtTokenProvider.createToken(username, "USER", HOUR); addCookie(response, "accessToken", accessToken, HOUR.intValue(), false); // JS 접근 가능 return true; } public void logout(HttpServletRequest request, HttpServletResponse response) { String token = extractToken(request); // (선택) Redis 처리 if (token != null && jwtTokenProvider.validateToken(token)) { // 2. 토큰의 남은 유효시간(TTL) 계산 long expiration = jwtTokenProvider.getExpiration(token); // 밀리초 단위 String username = jwtTokenProvider.getUsername(token); // 3. Redis에서 Refresh Token 삭제 (더 이상 갱신 불가하게 만듦) // redisTemplate.delete("RT:" + username); // 재사용되지 못하도록 Access Token을 블랙리스트에 등록 (Value는 아무 값이나 상관없음) // redisTemplate.opsForValue().set( // "BL:" + token, // "logout", // expiration, // TimeUnit.MILLISECONDS // ); } // 브라우저의 쿠키 무효화 (만료 시간을 0으로 설정) clearCookie(response, "accessToken"); clearCookie(response, "refreshToken"); } private void addCookie(HttpServletResponse response, String name, String value, int maxAge, boolean httpOnly) { Cookie cookie = new Cookie(name, value); cookie.setHttpOnly(httpOnly); // JavaScript 접근 방지 (XSS 보호) cookie.setSecure(true); // HTTPS 환경에서만 전송 cookie.setPath("/"); cookie.setMaxAge(maxAge); response.addCookie(cookie); } private String getCookie(HttpServletRequest request, String name) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals(name)) { return cookie.getValue(); } } } return null; } private void clearCookie(HttpServletResponse response, String name) { Cookie cookie = new Cookie(name, null); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(0); // 즉시 만료 response.addCookie(cookie); } private String extractToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return getCookie(request, "accessToken"); } }