IT

[Security] JWT 구현

data-cloud 2025. 1. 18. 11:31
반응형

 

 

 

 

🔐 JWT(Json Web Token)

- JWT란?

   JWT는 하나의 인터넷 표준 인증 방식으로 인증에 필요한 정보들을 암호화시킨 JSON 형태의 토큰을 말한다.

- JWT를 사용한 인증방식   

 

이전 포스팅에서 JWT의 개념에 대해서 알아보았다. 이번 포스팅에서는 스프링부트를 사용한 실제 적용방법을 알아보자.

 

[Security] JWT 소개

🔐 HTTP의 특징 및 쿠키(Cookie)와 세션(Session)의 등장 JWT를 소개하기 전에 HTTP의 특징과 쿠키와 세션의 등장 배경에 대하여 알아보자. 기본적으로 HTTP 프로토콜 환경은 'Connectionless', 'Stateless'한 특

caffeineoverflow.tistory.com

 

 

 

🔐 프로젝트 구조

전체 프로젝트 구성은 아래와 같으며, 각 각의 파일을 순차적으로 살펴보도록 하겠다.

 

 

 

🔐 Dependency 추가

build.gradle 파일에 JPA, H2 DB, 롬복, Spring security, JWT 등에 대한 의존성을 명시해준다.

dependencies {
    // Spring Basic
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // jpa
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // database
    runtimeOnly 'com.h2database:h2'

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

    // jwt
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-test'
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

 

 

 

🔐 설정 정보

application.yml 파일에 DB 정보 등에 대한 설정을 해준다.

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/test
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true


logging.level:
  org.hibernate.SQL: debug

 

 

 

🔐 도메인 및 비즈니스 로직

- Member.java
사용자 도메인 객체는 사용자ID, 이름, 비밀번호, 권한 정보를 가지고 있다.

package app.web.domain;

import lombok.*;
import javax.persistence.*;

@NoargsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    private String name;

    private String password;

    @Enumerated(EnumType.STRING)
    private MEMBER_ROLES roles;
    
    @Builder
    private Member(Long memberId, String name, String password, MEMBER_ROLES roles) {
        this.memberId = memberId;
        this.name = name;
        this.password = password;
        this.roles = roles;
    }

}

 

 

- MEMBER_ROLES.java

사용자 권한을 정의하고 있으며, 관리자(ADMIN) 또는 일반 사용자(USER) 권한으로 구성되어 있다.

package app.web.domain;

public enum MEMBER_ROLES {
    ADMIN, USER
}

 

 

- MemberController.java
회원 이름과 비밀번호를 입력하여 로그인에 성공하면 토큰을 발급한 후 응답을 내려주는 역할을 한다.

package app.web.controller.v1;

import app.web.domain.Member;
import app.web.jwt.JwtTokenProvider;
import app.web.service.MemberService;
import lombok.RequiredArgsConstructor;
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 java.util.Arrays;

@RestController
@RequiredArgsConstructor
public class MemberControllerV1 {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;

    /**
     * 로그인
     */
    @PostMapping("/v1/login")
    public ResponseEntity login(@RequestBody Member member) {
        Member findMember = memberService.findMemberByName(member.getName());
        if (findMember != null && findMember.getPassword().equals(member.getPassword())) {
            return ResponseEntity
                    .ok()
                    .body(jwtTokenProvider.createToken(String.valueOf(findMember.getMemberId()), Arrays.asList(findMember.getRoles().toString())));
        } else {
            return ResponseEntity
                    .status(HttpStatus.UNAUTHORIZED)
                    .body("로그인 정보가 올바르지 않습니다.");
        }
    }

}

 

 

- AuthController.java

권한에 따라 접근이 가능한지 테스트를 위한 컨트롤러이다.
회원 및 관리자는 '/v1/user/access'에 접근 가능하며, '/v1/admin/access'는 오직 관리자 권한이 있을 때만 접근이 가능하다.

package app.web.controller.v1;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthControllerV1 {

    /**
     * 회원과 관리자 권한 접근
     */
    @PostMapping("/v1/user/access")
    public ResponseEntity accessUser() {
        return ResponseEntity
                .ok()
                .body("Hello User World!");
    }

    /**
     * 관리자 권한 접근
     */
    @PostMapping("/v1/admin/access")
    public ResponseEntity accessAdmin() {
        return ResponseEntity
                .ok()
                .body("Hello Admin World!");
    }

}

 

 

- MemberService.java

회원 등록 및 조회 기능이 있다.

중점적으로 봐야할 점은 재정의한 loadUserByUsername() 메소드이다.
해당 클래스는 Spring Security에서 제공하는 UserDetailsService를 상속받은 서비스 클래스이다.

UserDetailsService클래스의 loadUserByUsername() 메소드를 재정의한 것으로 사용자 정보 및 권한 정보를 리턴한다.

이 리턴 값을 토대로 사용자가 유효한 사용자인지 요청한 자원에 대한 접근 권한이 있는지 판단한다.

 

package app.web.service;

import app.web.domain.Member;
import app.web.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;

    /**
     * 회원 등록
     */
    public Long saveMember(Member member) {
        return memberRepository.save(member).getMemberId();
    }

    /**
     * 회원 전체 조회
     */
    public List<Member> findAllMember() {
        return memberRepository.findAll();
    }

    /**
     * 회원 조회
     */
    public Member findMember(Long memberId) {
        return memberRepository.findByMemberId(memberId);
    }

    /**
     * 회원 조회
     */
    public Member findMemberByName(String name) {
        return memberRepository.findByName(name);
    }

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findByMemberId(Long.parseLong(userId));
        List<String> authList = new ArrayList<>();
        authList.add(member.getRoles().name());

        return new User(
                String.valueOf(member.getMemberId()),
                member.getPassword(),
                Arrays.asList(member.getRoles().name()).stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())
        );
    }

}

 

 

- MemberRepository.java

JPA를 사용하여 사용자를 조회하는 기능을 담당하고 있다.

package app.web.repository;

import app.web.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Member findByMemberId(Long memberId);

    Member findByName(String name);

}

 

 

 

🔐 JWT 핵심 로직

- JwtTokenProvider.java

토큰을 생성하고 토큰에서 정보를 추출하는 역할을 담당한다.

package app.web.jwt;

import app.web.service.MemberService;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.*;

@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {
    private String secretKey = "app";

    private long tokenValidTime = 3 * 60 * 1000L; // 토큰 유효시간(3분)

    private final MemberService memberService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk);
        claims.put("roles", roles);

        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = memberService.loadUserByUsername(this.getUserPk(token));
        Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        return auth;
    }

    // 토큰에서 PK로 사용된 값을 추출한다.
    public String getUserPk(String token) {
        String userPk = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        return userPk;
    }

    // 헤더정보에서 Authorization의 값을 추출한다.
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (SignatureException e) {
            log.error("Invalid JWT signature", e);
        } catch (MalformedJwtException e) {
            log.error("Invalid JWT token", e);
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token", e);
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token", e);
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty.", e);
        }

        return false;
    }
}

 

 

- JwtAuthenticationFilter.java
사용자 요청 값 중 헤더에서 토큰을 추출하여 토큰의 유효성을 검사한다.

package app.web.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); // 헤더에서 토큰 값 추출
        if (token != null && jwtTokenProvider.validateToken(token)) { // 토큰 유효성 검사
            Authentication authentication = jwtTokenProvider.getAuthentication(token); // 토큰을 토대로 인증정보를 추출
            SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰에서 추출한 인증정보 셋팅
        }
        chain.doFilter(request, response);
    }

}

 

 

 

반응형

 

 

 

- SecurityConfig.java

토큰에서 인증정보를 추출하여 토큰을 발급받은 사용자가 요청한 URL에 접근할 수 있는지 판단한다.

  • ADMIN 권한을 가진 사용자 : '/v1/admin/**'과 '/v1/user/**' 패턴과 일치하는 URL일 경우, 리소스에 접근이 가능하다.
  • USER 권한을 가진 사용자 : '/v1/user/**' 패턴과 일치하는 URL일 경우에만 리소스에 접근이 가능하다.
package app.web.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();

        http.httpBasic().disable()
                .authorizeRequests()
                // /v1/user/** 패턴으로 들어오는 요청에 대해서는 ADMIN 또는 USER 권한이 있어야힌다. 
                .antMatchers("/v1/user/**").hasAnyAuthority("ADMIN", "USER")
                // /v1/admin/** 패턴으로 들어오는 요청에 대해서는 ADMIN 권한이 있어야한다.(=ADMIN 권한만 접근 가능하다)
                .antMatchers("/v1/admin/**").hasAuthority("ADMIN")
                // 이외 요청에 대해서는 모두 접근이 가능하다.
                .anyRequest().permitAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

}

 

 

🔐 JWT 발급과 접근 테스트

테스트 사전 조건은 다음과 같다.

- 이름과 패스워드를 사용하여 로그인을 성공하면 토큰이 발급된다.

- admin은 'ADMIN' 권한을 가진 사용자이며, member-0 사용자는 'USER' 권한을 가진 사용자이다.



이제 아래의 순서로 테스트를 진행해보도록 하자.

  1. 관리자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 사용자 페이지(/v1/user/**)로 접근이 가능 여부 확인
    - 접근 가능하다.
  2. 관리자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 관리자 페이지(/v1/admin/**)로 접근이 가능 여부 확인
    - 접근 가능하다.
  3. 사용자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 사용자 페이지(/v1/user/**)로 접근이 가능 여부 확인
    - 접근 가능하다.
  4. 사용자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 관리자 페이지(/v1/admin/**)로 접근이 가능 여부 확인
    - 접근 불가능하다.

관리자 계정 로그인 및 토큰 발급

 

 

CASE 1. 관리자 토큰으로 /v1/user/** 접근 성공 모습

 

 

 

 

 

CASE 2. 관리자 토큰으로 /v1/admin/** 접근 성공 모습

 

 

 

일반사용자 계정 로그인 및 토큰 발급

 

 

CASE 3. 일반사용자 토큰으로 /v1/user/** 접근 성공 모습

 

 

 

CASE 4. 일반사용자 토큰으로 /v1/admin/** 접근 실패 모습

 

 

 

반응형

'IT' 카테고리의 다른 글

[SpringBoot] Bucket4j를 이용한 트래픽 제한  (2) 2025.01.18
[SpringBoot] REST Docs  (2) 2025.01.18
[Security] JWT 소개  (2) 2025.01.18
[Spring] 스프링 AOP  (1) 2025.01.18
[Java] 자바 개발환경 준비 - JDK 설치 및 환경변수 설정  (1) 2025.01.18