스프링부트 독학-9장 JWT로 로그인/로그아웃 구현하기

jaegeunsong97·2023년 9월 1일
0

출처

신서영개발자님의 스프링부트 책

새롭게 알게된 내용 정리

토큰 기반 인증

  • 사용자 인증 확인 방법

    • 서버 기반 인증 : 스프링 시큐리티에서 기본적으로 세션 기반 인증 제공
      • 세션 기반 인증을 통해 사용자마다 사용자의 정보를 담은 세션생성하고 저장해서 인증
    • 토큰 기반 인증
      • 토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값
      • 서버가 토큰을 생성해 클라이언트에게 제공
      • 클라이언트는 토큰을 가지고 여러 요청과 함께 신청
      • 서버는 토큰만 보고 유효한 사용자인지 검증
  • 토큰 기반 인증 특징

    • 무상태성 : 사용자의 인증 정보가 담겨 있는 토큰이 서버가 아닌 클라이언트에 있으므로 서버에 저장 X
      • 서버가 뭔가 데이터를 유지하고 있으려면 자원을 소비해야하지만
      • 토큰기반클라이언트가 인증정보가 담긴 토큰을 생성하고 인증함
      • 클라이언트는 사용자의 인증 상태를 유지하면서 이후 요청을 처리 = 상태관리
      • 서버클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되서, 무상태로 효율적인 검증 가능
    • 확장성 : 무상태성이 확장성에 영향
      • 서버를 확장할 때 상태 관리 신경 X -> 확장에 용이
      • 세션 기반 : 각각 API 인증 필요
      • 토큰 기반 : 주체가 Client
        • OAuth에도 사용 -> 다른 서비스에 권한을 공유 가능(주체가 Client니까)
    • 무결성
      • 토큰방식은 HMAC기법
      • 토큰을 발급한 이후에는 토큰 정보 변경 불가
        • 토큰의 무결성 보장
  • JWT

    • 발급받은 JWT를 이용해 인증하려면 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT
    • 구조
      • HEADER : 토큰 타입 + 해싱 알고리즘
      • PAYLOAD : 토큰과 관련된 정보
        • 내용의 한덩어리 : Claim
          • Key, Value
          • 종류
            • 등록된 클레임 : 토큰에 대한 정보
            • 공개 클레임 : 공개되어도 상관없는 클레임
              • 충돌 방지할 수 있는 이름 -> URI 많이 사용
            • 비공개 클레임 : 클라이어트와 서버간의 통신에 사용
      • SIGNATURE : 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용
        • HEADER의 인코딩 값 + PAYLOAD 인코딩 값 => + 비밀키 => SIGNATURE
  • HEADER

{
	"tpy": "JWT",
    "alg": "HS256"
}
이름설명
typ토큰의 타입을 지정, JWT라는 문자열 들어감
alg해싱 알고리즘 지정
  • PAYLOAD
이름설명
iss토큰 발급자(issuer)
sub토큰 제목(subject)
aud토큰 대상자(audience)
exp토큰의 만료시간(expiration), 시간은NumericDate형식(12312312341)으로 하며, 항상 현재 시간 이후로 설정
nbf토큰의 활성 날짜와 비슷한 개념으로 nbf는 Not Before를 의미, NumericDate 형식으로 날짜 지정, 이 날짜가 지나기 전까지 토큰 처리 X
iat토큰이 발급된 시간으로 iat는 issued at을 의미
jtiJWT의 고유식별자로 주로 일회용 토큰에 사용
{
	"iss": "jaegeunsong97@gmail.com", // 등록된 클레임
    "iat": 2023123022, // 등록된 클레임
    "exp": 2023123040, // 등록된 클레임
    "https://jaegeunsong.com/jwt_claims/is_admin": true, // 공개 클레임
    "email": "jaegeunsong97@gmail.com", // 비공개 클레임
	"hello": "안녕하세여" // 비공개 클레임
}
  • 리프래시 토큰
    • 유효기간을 짧게 가져가면 보안은 좋지만 매번 로그인 다시해야함
    • 이런 문제 해결하기 위해 리프래시토큰 등장
    • 액세스 토큰 만료시 리프래시 토큰으로 새로운 엑세스 토큰 발급

JWT 서비스 구현

  • 의존성 추가
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    implementation 'io.jsonwebtoken:jjwt:0.9.1' // 자바 JWT 라이브러리
    implementation 'javax.xml.bind:jaxb-api:2.3.1' // XM 문서와 Java 객체 간 매핑 자동화

    runtimeOnly 'com.h2database:h2'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}
  • application.yml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true
  datasource:
    url: jdbc:h2:mem:testdb
    username: song
  h2:
    console:
      enabled: true
jwt:
  issuer: jaegeunsong97@gmail.com # 이슈 발급자
  secret_key: study-springboot # 비밀키
  • /config/jwtJwrProperties
    • @ConfigurationProperties : yml에 설정된 jwt 값들 가져옴
@Getter
@Setter
@Component
@ConfigurationProperties("jwt") // Java 에서 Properties 값을 가져옴
public class JwtProperties {

    private String issuer;
    private String secretKey;
}
  • TokenProvider
    • generateToken() : 토큰을 생성하는 메소드
    • validToken() : 토큰 유효성 검사 메소드
    • getAuthentication() : 토큰으로 전달받아 인증 정보를 담은 객체 Authentication 반환하는 메소드
    • getUserId() : 토큰 기반으로 유저 ID를 가져오는 메소드
@Service
@RequiredArgsConstructor
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);
    }

    // 1. JWT 토큰 생성
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                // HEADER: typ
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 type : JWT
                // PAYLOAD: iss, iat, exp, sub, claim
                .setIssuer(jwtProperties.getIssuer()) // 내용 iss : jaegeunsong97@gmail.com(propertise 파일에서 설정한 값)
                .setIssuedAt(now) // 내용 iat : 현재시간
                .setExpiration(expiry) // 내용 exp : expiry 멤버 변수값
                .setSubject(user.getEmail()) // 내용 sub : 유저의 이메일
                .claim("id", user.getId()) // 클레임 id : 유저 ID
                // SIGN
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
                .compact();
    }

    // 2. JWT 토큰 유효성 검증 메소드
    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey()) // 복호화
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false; // 에러 발생 = 유효하지 않은 토큰
        }
    }

    // 3. 토큰 기반으로 인증 정보를 가져오는 메소드, 즉 인증 정보를 담은 객체 Authentication 반환
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token); // getClaims : 복호화 -> Claim 가져옴
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(
        		// 다른 User
                new org.springframework.security.core.userdetails.User(
                        claims.getSubject(), "", authorities), token, authorities); 
    }

    // 4. 토큰 기반으로 유저 ID를 가져오는 메소드
    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey()) // 복호화
                .parseClaimsJws(token)
                .getBody(); // 내용 sub : 유저의 이메일 꺼내기
    }
}
  • 리프레시 토큰 도메인
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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 newRefreshToken) {
        this.refreshToken = newRefreshToken;
        return this;
    }
}
  • RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
  • 토큰 필터 구현하기

    • 필터 : 각종 요청을 처리하기 위한 로직전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리해주는 것
    • 요청이 오면 HEADER값을 비교 후, 토큰이 있는지 확인
      • 유효한 토큰 -> SecurityContextHolder에 인증 정보 저장
    • SecurityContext : 인증 정보가 저장되는 객체
      • 인증정보가 필요할 떄 언제든지 꺼내 사용 가능
      • thread local(스레드 마다 공간을 할당)에 저장되므로 코드의 아무 곳에서나 참조 가능
        • 다른 스레드와 공유 X
      • SecurityContext 객체를 저장하는 객체 : SecurityContextHolder
  • TokenAuthenticationFilter

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 요청 헤더의 Authorization 키의 값 조회
        String authorizationHeader = response.getHeader(HEADER_AUTHORIZATION);
        // 가져온 값에서 접두사 제거
        String token = getAccessToken(authorizationHeader);
        // 가져온 토큰이 유효한지 확인하고, 유효할 때는 인증 정보 설정
        if (tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
    		// SecurityContext에 인증 정보 저장
    		SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
	
    // Bearer 제거
    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

토큰 API 구현

리프레시 토큰을 받아 토큰 제공자를 사용해 새로운 엑세스 토큰 만들기

  • UserService
public User findById(Long userId) {
        return userRepository.findById(userId).orElseThrow(
                () -> new IllegalArgumentException("Unexpected user")
        );
    }
  • RefreshTokenService
@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken).orElseThrow(
                () -> new IllegalArgumentException("Unexpected token")
        );
    }
}
  • TokenService
@Service
@RequiredArgsConstructor
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("Unexpected token");
        }

        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        User user = userService.findById(userId);
        return tokenProvider.generateToken(user, Duration.ofHours(2)); // access 토큰 짧게
    }
}
  • dto 만들기
@Getter
@Setter
public class CreateAccessTokenRequest {

    private String refreshToken;
}
.
.
.
@Getter
@AllArgsConstructor
public class CreateAccessTokenResponse {

    private String accessToken;
}
  • TokenApiController
@RestController
@RequiredArgsConstructor
public class TokenApiController {

    private final TokenService tokenService;

    @PostMapping("/api/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(
            @RequestBody CreateAccessTokenRequest request) {
        String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new CreateAccessTokenResponse(newAccessToken));
    }
}
  • 핵심정리
    • 토큰 기반 인증
      • 인증에 토큰을 사용하는 방식
      • 토큰을 Client를 구분하는 유일한 값
      • 서버에서 생성해서 Client에게 제공
      • Client는 서버에게 요청할 때마다 함께 토큰 전송
      • 서버에서는 유효한 사용자인지 검증
    • JWT
      • 토큰 기반 인증에서 사용
      • HEADER : 타입, 해싱알고리즘
      • PAYLOAD : 토큰에 담을 정보
      • SIGNATURE : 위에 2개 합친거(인코딩 + 비밀키)
    • 리프레시 토큰
      • 엑세스 토큰 만료시 엑세스 토큰 발급해주는 것
    • 필터
      • 요청의 전과 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능
    • 시큐리티 컨텍스트
      • 인증정보가 저장되는 객체
      • 시큐리티 컨텍스트 홀더에 시큐리티 컨텍스트가 저장됨
profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글