[첫 사이드프로젝트 도전기] JWT - 토큰 기반 인증을 구현해볼까요?

Minju Kim·2024년 9월 2일
0

사이드프로젝트

목록 보기
8/9

JWT를 사용한 토큰 기반 인증

  • 기존의 세션 기반 인증과는 다르게, 무상태성을 유지하면서도 보안을 강화할 수 있는 방법

토큰 기반 인증의 흐름

토큰 기반 인증이 어떤 식으로 이루어지는지 간단히 정리함.

  1. 클라이언트가 로그인 요청을 보냄.
  2. 서버가 클라이언트의 자격 증명을 확인하고 인증해줌.
  3. 서버는 클라이언트에게 토큰을 발급함.
  4. 클라이언트는 이 토큰을 저장해두고, 이후 요청할 때마다 이 토큰을 함께 보냄.
  5. 서버는 토큰의 유효성을 검사하고, 해당 요청이 유효한지 검증한 후 인가함.

이 과정을 통해 클라이언트와 서버 간의 인증이 이루어짐.

토큰 기반 인증의 장점

  • 무상태성: 서버가 상태를 유지하지 않으므로 확장성이 좋음.
  • 확장성: 하나의 토큰으로 여러 요청을 처리할 수 있음.
  • 무결성: 토큰이 발급된 후에는 변경될 수 없으므로 무결성을 보장함.

JWT로 인증 구현하기

JWT를 사용해 인증을 구현하는 과정임. JWT는 JSON Web Token의 약자로, 클라이언트와 서버 간에 정보를 안전하게 전달하기 위한 표준임.

1. Gradle에 JWT 라이브러리 추가

우선 build.gradle 파일에 JWT 관련 라이브러리를 추가함.

dependencies {
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    implementation 'javax.xml.bind:jaxb-api:2.3.1'
}

JWT 토큰을 생성하고, 서명하며, 검증할 때 사용됨.

2. JWT 설정 추가

JWT 설정을 application.yml 파일에 추가함. application-dev.yml 파일에 다음과 같은 내용을 추가함.

jwt:
  issuer: your-email@example.com
  secret-key: study-springboot-sideproject-first
  • issuer: 토큰을 발급하는 주체로, 이메일 주소나 도메인을 사용함.
  • secret-key: 토큰을 서명하는 데 사용될 비밀 키임. 충분히 복잡한 문자열을 사용해야 함.

3. JwtProperties 작성

이 설정값을 사용하기 위해 JwtProperties 클래스를 작성함.

package com.mylittleproject.firstlittleproject.config.jwt;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {
	private String issuer;
	private String secretKey;
}

이 클래스는 Spring Boot의 @ConfigurationProperties 애노테이션을 사용해 application.yml에 정의된 jwt 속성값을 읽어옴.

4. TokenProvider 구현

토큰을 생성하고 검증하는 역할을 하는 TokenProvider를 작성함.

package com.mylittleproject.firstlittleproject.config.jwt;

import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import com.mylittleproject.firstlittleproject.user.entity.User;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class TokenProvider {

	private final JwtProperties jwtProperties;

	public String generateToken(User user, Duration expiredAt) {
		Date now = new Date();
		return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
	}

	private String makeToken(Date expiredAt, User user) {
		Date now = new Date();

		return Jwts.builder()
			.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
			.setIssuer(jwtProperties.getIssuer())
			.setIssuedAt(now)
			.setExpiration(expiredAt)
			.setSubject(user.getEmail())
			.claim("id", user.getId())
			.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
			.compact();
	}

	public boolean validToken(String token) {
		try {
			Jwts.parser()
				.setSigningKey(jwtProperties.getSecretKey())
				.parseClaimsJws(token);

			return true;
		} catch (Exception e) {
			return false;
		}
	}

	public Authentication getAuthentication(String token) {
		Claims claims = getClaims(token);
		Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
		return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
	}

	public Long getUserId(String token) {
		Claims claims = getClaims(token);
		return claims.get("id", Long.class);
	}

	public Claims getClaims(String token) {
		return Jwts.parser()
			.setSigningKey(jwtProperties.getSecretKey())
			.parseClaimsJws(token)
			.getBody();
	}
}
  • generateToken: 사용자 정보를 바탕으로 JWT 토큰을 생성함.
  • validToken: 전달받은 토큰이 유효한지 검증함.
  • getAuthentication: 토큰을 바탕으로 인증 정보를 가져옴.
  • getUserId: 토큰에서 사용자 ID를 추출함.

5. Refresh Token과 DTO 작성

JWT를 사용할 때 보통 Access Token과 Refresh Token을 함께 사용함. Access Token의 유효기간은 짧게, Refresh Token은 더 길게 설정함. 먼저 DTO를 record 타입으로 작성함.

package com.mylittleproject.firstlittleproject.user.dto;

public record CreateAccessTokenRequest(
	String refreshToken
) {
}

public record CreateAccessTokenResponse(
	String accessToken
) {
}
  • CreateAccessTokenRequest: 새로운 Access Token을 요청할 때 사용됨.
  • CreateAccessTokenResponse: 새로운 Access Token이 생성되었을 때 반환됨.

6. RefreshToken 엔티티와 리포지토리 작성

RefreshToken을 데이터베이스에 저장하기 위한 엔티티를 작성함.

package com.mylittleproject.firstlittleproject.user.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Getter
@Entity
public class RefreshToken {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", updatable = false)
	private Long id;

	@Column(name = "user_id", nullable = false, unique = true)
	private Long userId;

	@Column(name = "refresh_token", nullable = false)
	private String refreshToken;

	public RefreshToken(Long userId, String refreshToken) {
		this.userId = userId;
		this.refreshToken = refreshToken;
	}

	public RefreshToken update(String refreshToken) {
		this.refreshToken = refreshToken;
		return this;
	}
}

그리고 RefreshToken을 관리할 리포지토리도 작성함.

package com.mylittleproject.firstlittleproject.user.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import com.mylittleproject.firstlittleproject.user.entity.RefreshToken;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

	Optional<RefreshToken> findByUserId(Long userId);
	Optional<RefreshToken> findByRefreshToken(String refreshToken);

}

7. 서비스와 컨트롤러 작성

서비스를 구현해서 JWT 인증을 처리하는 코드를 작성함.

package com.mylittleproject.firstlittleproject.user.service;

import java.time.Duration;

import org.springframework.stereotype.Service;

import com.mylittleproject.firstlittleproject.config.jwt.TokenProvider;
import com.mylittleproject.firstlittleproject.user.entity.User;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class TokenService {

	private final TokenProvider tokenProvider;
	private final RefreshTokenService refreshTokenService;
	private final UserService userService;

	public String createNewAccessToken(String refreshToken) {
		// 토큰 유효성 검사 실패 -> 예외 발생
		if (!tokenProvider.validToken(refreshToken)) {
			throw new IllegalArgumentException("Invalid refresh token");
		}
		Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
		User user = userService.findById(userId);

		return tokenProvider.generateToken(user, Duration.ofHours(2));
	}
}

이 서비스는 유효한 Refresh Token을 바탕으로 새로운 Access Token을 생성함.

그리고 컨트롤러도 작성해서 API 엔드포인트를 만듦.

package com.mylittleproject.firstlittleproject.user.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.mylittleproject.firstlittleproject.user.dto.CreateAccessTokenRequest;
import com.mylittleproject.firstlittleproject.user.dto.CreateAccessTokenResponse;
import com.mylittleproject.firstlittleproject.user.service.TokenService;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RestController
public class TokenApiController {
	private final TokenService tokenService;

	@PostMapping("/api/token")
	public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
		String newAccessToken = tokenService.createNewAccessToken(request.refreshToken());

		return ResponseEntity.status(HttpStatus.CREATED).body(new CreateAccessTokenResponse(newAccessToken));
	}
}

이 컨트롤러는 /api/token 경로로 새로운 Access Token을 요청하는 API를 제공함.

8. RefreshTokenService 구현

Refresh Token을 관리하는 서비스를 작성함.

package com.mylittleproject.firstlittleproject.user.service;

import org.springframework.stereotype.Service;

import com.mylittleproject.firstlittleproject.user.entity.RefreshToken;
import com.mylittleproject.firstlittleproject.user.repository.RefreshTokenRepository;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class RefreshTokenService {

	private final RefreshTokenRepository refreshTokenRepository;

	public RefreshToken findByRefreshToken(String refreshToken) {
		return refreshTokenRepository.findByRefreshToken(refreshToken)
			.orElseThrow(() -> new IllegalArgumentException("Unexpected Token"));
	}
}

이 서비스는 Refresh Token을 데이터베이스에서 조회하고 관리함.


0개의 댓글