본문 바로가기

Spring Boot

[Spring Boot] AccessToken 및 RefreshToken 인증 구현하기

728x90

쿠키, 세션, 토큰이란?

1. 쿠키 (Cookie)

  • 쿠키는 웹 브라우저에 저장되는 작은 데이터 조각으로, 사용자의 정보나 상태를 추적하는 데 사용됩니다.
  • 예를 들어, 사용자가 로그인할 때 서버는 로그인 정보를 쿠키로 브라우저에 저장하고, 그 후 사용자 요청이 있을 때마다 이 쿠키를 사용하여 사용자를 인증할 수 있습니다.
  • 특징:
    • 클라이언트 측에 저장됩니다.
    • 만료 시간이나 특정 정보(예: 사용자 ID, 로그인 상태)를 설정할 수 있습니다.
    • 보통 HTTP 요청 시 자동으로 서버에 전송됩니다.
    • 보안: HTTPOnly와 Secure 속성을 사용하여 보안성을 강화할 수 있습니다.

2. 세션 (Session)

  • 세션은 서버 측에서 사용자의 상태 정보를 저장하는 방식입니다. 사용자가 로그인하면, 서버는 세션을 생성하여 고유한 세션 ID를 클라이언트(웹 브라우저)에 전달합니다.
  • 클라이언트는 이후 요청 시 이 세션 ID를 서버에 전송하여 서버가 세션 정보를 기반으로 사용자를 식별합니다.
  • 특징:
    • 서버 측에 저장된 데이터로 사용자의 상태를 추적합니다.
    • 서버가 클라이언트에게 세션 ID를 발급하고, 이 ID는 보통 쿠키를 통해 클라이언트에 저장됩니다.
    • 보안: 서버에서 세션 데이터를 관리하기 때문에 쿠키보다 보안성이 높습니다. 하지만 세션이 서버 측에서 관리되므로 서버의 부하가 늘어날 수 있습니다.

3. 토큰 (Token)

  • 토큰은 서버와 클라이언트 간에 정보를 교환할 때 사용되는 보안 인증 수단입니다. 일반적으로 JWT(Json Web Token) 형식으로 사용되며, 사용자 인증 및 권한 부여에 사용됩니다.
  • 토큰은 서버에서 생성되어 클라이언트에 전달되며, 이후 클라이언트는 이 토큰을 요청에 첨부하여 인증을 받습니다.
  • 특징:
    • 주로 Stateless 방식으로 동작하여 서버에 상태 정보를 저장하지 않습니다.
    • 클라이언트 측에서 토큰을 저장하고 요청할 때마다 서버로 전달합니다.
    • 세션 기반 인증 방식보다 더 효율적이고 확장성이 좋습니다.

 

 

쿠키 세션을 사용하지 않고 토큰을 사용하는 이점

  1. Stateless (무상태성)
    • 토큰 기반 인증은 서버 측에 상태를 저장하지 않기 때문에 Stateless 방식을 따릅니다. 즉, 서버는 클라이언트의 상태를 기억하지 않아도 됩니다. 이로 인해 서버가 더 확장 가능하고, 여러 서버에 분산 처리할 때 유리합니다.
  2. 서버 부하 감소
    • 세션은 서버 측에서 데이터를 관리하므로 서버의 부하가 증가할 수 있습니다. 반면, 토큰은 클라이언트 측에서 관리되므로 서버의 저장소에 부담을 주지 않습니다.
  3. 스케일링 용이
    • 토큰은 클라이언트에서 독립적으로 작동하므로, 여러 서버 간에 세션 정보를 공유할 필요가 없습니다. 이는 서버 확장 시 매우 유리합니다.
  4. 다양한 클라이언트 지원
    • 토큰은 브라우저뿐만 아니라 모바일 애플리케이션이나 API와 같은 다양한 클라이언트에서도 사용할 수 있습니다. 반면, 세션은 브라우저에 의존적입니다.
  5. Cross-Domain 인증 지원
    • 토큰은 도메인 간 인증을 쉽게 처리할 수 있어, 여러 도메인에 걸쳐 인증을 처리해야 하는 경우 유용합니다. 쿠키나 세션은 주로 동일 도메인 내에서 사용됩니다.

 

 

AccessToken이란?

  • Access Token은 클라이언트가 서버에 요청을 보낼 때, 자신의 인증을 증명하기 위해 사용하는 짧은 유효기간을 가진 토큰입니다.
  • 사용자가 로그인한 후 서버는 AccessToken을 생성하고 이를 클라이언트에 전달합니다. 클라이언트는 이후 서버에 요청할 때 이 토큰을 포함시켜 인증을 받습니다.
  • 특징:
    • 유효 기간이 짧아 자주 갱신해야 합니다.
    • 서버는 AccessToken을 통해 사용자의 인증 정보를 확인하고, 해당 사용자가 권한을 가지고 있는지 판단합니다.
    • 예시: JWT(JSON Web Token)는 대표적인 AccessToken 형식입니다.

 

 

RefreshToken이란?

  • Refresh TokenAccessToken의 유효 기간이 만료되었을 때, 새로운 AccessToken을 발급받을 수 있게 해주는 토큰입니다. 즉, AccessToken을 갱신하는 데 사용됩니다.
  • 사용자가 로그인하면 서버는 AccessToken과 함께 RefreshToken을 클라이언트에 전달합니다. AccessToken이 만료되면 클라이언트는 RefreshToken을 서버에 보내 새로운 AccessToken을 발급받습니다.
  • 특징:
    • AccessToken보다 유효 기간이 길고 상대적으로 보안이 강화된 방식으로 저장됩니다.
    • 주로 서버와의 상호작용에서만 사용되며, 클라이언트의 민감한 데이터를 포함하지 않습니다.
    • RefreshToken을 이용한 인증은 세션을 유지하면서 AccessToken을 새롭게 갱신하는 방식입니다.
    • 단점: RefreshToken 자체가 유출될 경우 보안에 위협이 될 수 있으므로, RefreshToken을 안전하게 저장하고 관리하는 것이 중요합니다.

 

 

AccessToken과 RefreshToken의 관계

  • AccessToken은 짧은 유효 기간을 가지며, 사용자가 새로운 요청을 할 때마다 인증을 제공합니다.
  • RefreshTokenAccessToken의 유효 기간이 만료되면, 새로운 AccessToken을 요청하는 데 사용됩니다.
    • 즉, AccessToken은 주로 요청을 인증하는 데 사용되며, RefreshToken은 새로운 AccessToken을 발급받기 위한 키로 사용됩니다.

 

 

스프링 실습 정리

1. 의존성 설치

실습을 위해 jjwt 라이브러리를 의존성으로 추가합니다. 아래 의존성을 build.gradle 또는 pom.xml에 추가해야 합니다.

// Gradle 예시
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

2. application.yaml 설정

Spring Boot 설정 파일인 application.yaml을 사용하여 토큰 관련 설정을 추가합니다.

spring:
  application:
    name: security-token

token:
  secret:
    key: "SpringBootJWTTokenSecretKeyTest123123321321"  # Secret Key
  access:
    expiration: 60  # Access Token 만료 시간 (60초)
  refresh:
    expiration: 120  # Refresh Token 만료 시간 (120초)

 

 

3. JwtTokenProvider 클래스

JWT 토큰을 생성하고 검증하는 JwtTokenProvider 클래스를 작성합니다. 이 클래스는 accessToken과 refreshToken을 생성하고 검증할 수 있는 메서드를 제공합니다.

@Component
public class JwtTokenProvider {

    @Value("${token.secret.key}")
    private String SECRET_KEY;

    @Value("${token.access.expiration}")
    private long ACCESS_TOKEN_EXPIRATION;

    @Value("${token.refresh.expiration}")
    private long REFRESH_TOKEN_EXPIRATION; // 2분

    // Access Token 생성
    public String createAccessToken(String userId) {
        System.out.println("AccessToken create");
        return createToken(userId, ACCESS_TOKEN_EXPIRATION);
    }

    // Refresh Token 생성
    public String createRefreshToken(String userId) {
        System.out.println("RefreshToken create");
        return createToken(userId, REFRESH_TOKEN_EXPIRATION);
    }

    // 토큰 생성 로직
    private String createToken(String userId, long expiration) {
        return Jwts.builder()
                .setSubject(userId)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) // 만료 시간 (밀리초로 변환)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    // 토큰에서 사용자 ID 추출
    public String getUserIdFromToken(String token) {
        return Jwts.parserBuilder() // parser() 대신 parserBuilder() 사용
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder() // parser() 대신 parserBuilder() 사용
                    .setSigningKey(SECRET_KEY)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            // 만료된 토큰을 별도로 처리할 수 있음
            return false;
        } catch (JwtException | IllegalArgumentException e) {
            // 다른 예외 처리
            return false;
        }
    }

    // 쿠키에서 특정 이름의 값 가져오기
    public String getTokenFromCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if (cookie.getName().equals(cookieName)) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}

 

4. 로그인 요청 처리 (AuthController)

로그인 요청을 처리하는 AuthController에서는 사용자가 로그인하면 AccessToken과 RefreshToken을 생성하고, 이를 클라이언트에게 쿠키로 전달합니다.

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(
            @RequestBody Map<String, String> loginRequest,
            HttpServletResponse response) {

        String userId = loginRequest.get("userId");
        String password = loginRequest.get("password");

        // 예제: 간단한 유저 검증
        if ("testUser".equals(userId) && "password123".equals(password)) {
            String accessToken = jwtTokenProvider.createAccessToken(userId);
            String refreshToken = jwtTokenProvider.createRefreshToken(userId);

            // 쿠키 설정
            Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(1 * 60); // 1분

            Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
            refreshTokenCookie.setHttpOnly(true);
            refreshTokenCookie.setPath("/");
            refreshTokenCookie.setMaxAge(2 * 60); // 2분

            response.addCookie(accessTokenCookie);
            response.addCookie(refreshTokenCookie);

            // 응답 데이터
            Map<String, String> tokens = new HashMap<>();
            tokens.put("message", "로그인 성공");

            return ResponseEntity.ok(tokens);
        } else {
            return ResponseEntity.status(401).body(Map.of("message", "로그인 실패"));
        }
    }
}

 

 

5. 유저 정보 가져오기 (UserController)

유저 정보 요청 시, accessToken 또는 refreshToken을 이용하여 인증 후 유저 정보를 반환합니다.

@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {

    private final JwtTokenProvider jwtTokenProvider;

    @GetMapping("/info")
    public ResponseEntity<?> getUserInfo(HttpServletRequest request, HttpServletResponse response) {
        // 쿠키에서 토큰 가져오기
        String accessToken = jwtTokenProvider.getTokenFromCookie(request, "accessToken");
        String refreshToken = jwtTokenProvider.getTokenFromCookie(request, "refreshToken");

        // 액세스 토큰 검증
        if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) {
            String userId = jwtTokenProvider.getUserIdFromToken(accessToken);

            // 유저 정보 반환
            Map<String, String> userInfo = new HashMap<>();
            userInfo.put("userId", userId);
            userInfo.put("name", "Test User"); // 예제 데이터
            userInfo.put("email", "user@example.com");
            System.out.println("accessToken : 유저 정보");

            return ResponseEntity.ok(userInfo);
        }

        // 액세스 토큰이 유효하지 않을 경우, 리프레시 토큰 검증
        if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
            String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);

            // 새로운 액세스 토큰 생성
            String newAccessToken = jwtTokenProvider.createAccessToken(userId);

            // 새 액세스 토큰을 쿠키로 전달
            Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(15 * 60); // 15분

            response.addCookie(accessTokenCookie);

            // 유저 정보 반환
            Map<String, String> userInfo = new HashMap<>();
            userInfo.put("userId", userId);
            userInfo.put("name", "Test User"); // 예제 데이터
            userInfo.put("email", "user@example.com");
            System.out.println("refreshToken : 유저 정보");

            return ResponseEntity.ok(userInfo);
        }

        // 둘 다 유효하지 않은 경우
        return ResponseEntity.status(401).body("로그인하세요.");
    }
}

 

728x90