천쓰의 개발동산

Spring의 JsonWebToken(JWT)로 안전하게 회원관리하기!! 본문

JAVA/SPRINGBOOT

Spring의 JsonWebToken(JWT)로 안전하게 회원관리하기!!

인천쓰 2024. 1. 17. 20:46
반응형

종합 프로젝트때 보안처리를 위해 JWT 토큰을 사용하였다 .

jwt 토큰이란 access토큰, refresh토큰 이두개를 이용하여 로그인시 발급받아 회원에 필요한기능을 사용할때
검증을 받아 사용하게됩니다 .  

 

이래에는 GPT가 말해주는 특징 입니다

 

JWT는 다음과 같은 특징을 가지고 있습니다:

  1. 자가수용적(Self-contained): 토큰에 필요한 모든 정보가 포함되어 있어 별도의 저장 공간이나 데이터베이스에 의존하지 않습니다.
  2. JSON 기반: 토큰의 정보 표현은 JSON 객체로 이루어져 있어 가독성이 좋고 다양한 데이터 유형을 지원합니다.
  3. 디지털 서명 또는 암호화: 필요에 따라 토큰은 디지털 서명 또는 암호화를 통해 보호될 수 있습니다. 이를 통해 토큰이 변조되지 않았음을 검증할 수 있습니다.

이로서 쉽게말해서는  인증 및 정보 교환을 위한 토큰 기반의 권한 부여 메커니즘? 이라보면될꺼같습니다

토큰을 만들기에앞서 필요한 함수가있습니다 .  

토큰을 생성,검증,회원정보추출 해주는  TokenProvider 

토큰을 인증처리를 해주는 JwtFilter

토큰의 보안 처리를해주는 WebSecurityConfig

권한이없는경우 에러를 처리해주는 Config와 DeniedHandler

그리고 토큰으로 회원정보를 가져오는 SecurityUtil이 있습니다 . 

 

이렇게 준비가되면 Token을 사용할 준비는 끝났습니다 .

 

 TokenProvider 

@Slf4j
@Component
public class TokenProvider {
    private static final String AUTHORITIES_KEY = "auth"; // 토큰에 저장되는 권한 정보의 key
    private static final String BEARER_TYPE = "Bearer"; // 토큰의 타입
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 1; // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7L; // 1일
    private final Key key; // 토큰을 서명하기 위한 Key

    // 주의점 : @Value 어노테이션은 springframework의 어노테이션이다.
    public TokenProvider(@Value("${jwt.secret}") String secretKey) {
        this.key = Keys.secretKeyFor(SignatureAlgorithm.HS512); // HS512 알고리즘을 사용하는 키 생성
    }

    // 토큰 생성
    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한 정보 문자열 생성,
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new java.util.Date()).getTime(); // 현재 시간
        // 토큰 만료 시간 설정
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);

        // 토큰 생성
        String accessToken = io.jsonwebtoken.Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        // 리프레시 토큰 생성
        String refreshToken = io.jsonwebtoken.Jwts.builder()
                .setExpiration(refreshTokenExpiresIn)
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        // 토큰 정보를 담은 TokenDto 객체 생성
        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .refreshTokenExpiresIn(refreshTokenExpiresIn.getTime())
                .build();
    }
    // 토큰에서 회원 정보 추출
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        // 토큰 복호화에 실패하면
        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 토큰에 담긴 권한 정보들을 가져옴
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // 권한 정보들을 이용해 유저 객체를 만들어서 반환, 여기서 User 객체는 UserDetails 인터페이스를 구현한 객체
        User principal = new User(claims.getSubject(), "", authorities);

        // 유저 객체, 토큰, 권한 정보들을 이용해 인증 객체를 생성해서 반환
        return new UsernamePasswordAuthenticationToken(principal, accessToken, authorities);
    }

    // 토큰의 유효성 검증
    public boolean validateToken(String token) {
        try {
            io.jsonwebtoken.Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | io.jsonwebtoken.MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
    // 토큰 복호화
    private Claims parseClaims(String accessToken) {
        try {
            return io.jsonwebtoken.Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
    // access 토큰 재발급
    public String generateAccessToken(Authentication authentication) {
        return generateTokenDto(authentication).getAccessToken();
    }
}

 

저는 access 권한시간을 refesh 토큰을 확인하느랴 1분으로 수정해서 사용했습니다 . 

 

JwtFilter

@Slf4j
@RequiredArgsConstructor // final이 붙은 필드를 인자값으로 하는 생성자를 만들어줌
public class JwtFilter  extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization"; // 토큰을 요청 헤더의 Authorization 키에 담아서 전달
    public static final String BEARER_PREFIX = "Bearer "; // 토큰 앞에 붙는 문자열
    private final TokenProvider tokenProvider; // 토큰 생성, 토큰 검증을 수행하는 TokenProvider

    private String resolveToken(HttpServletRequest request) { // 토큰을 요청 헤더에서 꺼내오는 메서드
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER); // 헤더에서 토큰 꺼내오기
        if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) { // 토큰이 존재하고, 토큰 앞에 붙는 문자열이 존재하면
            return bearerToken.substring(7); // 토큰 앞에 붙는 문자열을 제거하고 토큰 반환
        }
        return null;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request);
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
} 

보기 쉽게 gpt로 설명 주석 달아놨습니다 ^^

 

WebSecurityConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@Component
public class WebSecurityConfig implements WebMvcConfigurer {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; // 인증 실패 시 처리할 클래스
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler; // 인가 실패 시 처리할 클래스
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // BCrypt 암호화 객체를 Bean으로 등록
    }

    @Bean // SecurityFilterChain 객체를 Bean으로 등록
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http

                .httpBasic()
                .and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/auth/**", "/admin/**","/api/**","/feed/**","/post/**","/","/static/**","/favicon.ico","/manifest.json").permitAll() //포스트맨 권한
                .antMatchers("/swagger-ui.html", "/v2/api-docs", "/swagger-resources/**", "/webjars/**").permitAll() //스웨거 권한
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}

저는 스웨거를 쓰기떄문에 스웨거 부문 추가로 만들어줫고 antMatchers쪽에 작업을할땐 ("/*") 로 Controll쪽에 모두 허용시켜 사용하였습니다 (이렇게하면 추후에 작업해줘야할게생김 편의상 ,,)

 

그리고 권한이 없는경우 에러처리는 생략하도록하고 

 

SecurityUtil

@Slf4j
@Component
public class SecurityUtil {
    private SecurityUtil() {
    }
    // Security Context의 Authentication 객체를 이용해 회원의 정보를 가져온다.
    public static Long getCurrentMemberId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || authentication.getName() == null) {
            throw new RuntimeException("Security Context에 인증 정보가 없습니다.");
        }
        return Long.parseLong(authentication.getName());
    }
}

 

해서 저장된 회원정보인 Long 타입의 id값을 가져옵니다 .

 

토큰안에 payload라는게 있는데 토큰을생성할때 그안에 회원정보를 저장하는 방식이라 그것을 가져와 유효한 토큰값만있으면 회원정보를 가져와 사용할수있다 .

 

이로서 토큰을 사용할 준비는 끝났습니다 .
이제 서비스와 컨트롤을 만들어 회원가입할때 비밀번호 인코딩하게 만들고 로그인할때 Spring Security 쪽에서 인코딩한 비밀번호와 유저가친 비밀번호를 확인하고 acess토큰   refresh토큰 을 출력하게해준다면 jwt의 토큰을 인증받아 사용만 하면됩니다 .! 

AuthService

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
    private final AuthenticationManagerBuilder managerBuilder; // 인증을 담당하는 클래스
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final TokenProvider tokenProvider;
    // 회원가입
    public MemberResDto signup(MemberReqDto requestDto) {
        if (memberRepository.existsByMemberEmail(requestDto.getMemberEmail())) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다");
        }
        Member member = requestDto.toEntity(passwordEncoder);
        return MemberResDto.of(memberRepository.save(member));
    }
    // 비밀번호변경
    public boolean passwordChange(String email, String password) {
        try {
            Member member = memberRepository.findByMemberEmail(email)
                    .orElseThrow(() -> new RuntimeException("회원 정보가 없습니다."));

            String hashedPassword = passwordEncoder.encode(password);
            member.setMemberPassword(hashedPassword);
            memberRepository.save(member);

            return true; // 변경 성공 시 true 반환
        } catch (Exception e) {
            // 예외가 발생하면 변경 실패로 간주하고 false 반환
            return false;
        }
    }


    public TokenDto login(MemberReqDto requestDto) {
        UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication();
        log.info("authenticationToken: {}", authenticationToken);

        Authentication authentication = managerBuilder.getObject().authenticate(authenticationToken);
        log.info("authentication: {}", authentication);

        return tokenProvider.generateTokenDto(authentication);
    }

    public String findId(String name, String tel) {
        Member member = memberRepository.findByMemberNameAndMemberTel(name, tel).orElseThrow(
                () -> new RuntimeException("해당 회원이 존재하지 않습니다.")
        );
        return member.getMemberEmail();
    }

    // accessToken 재발급
    public String createAccessToken(String refreshToken) {
        Authentication authentication = tokenProvider.getAuthentication(refreshToken);
        return tokenProvider.generateAccessToken(authentication);
    }
}

 

여기서 재발급은 refresh토큰을 받아와 accessToken의 시간 만료일떄 재발급해주는 매서드입니다.

AuthController

@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;
    private final MemberService memberService;
    private final EmailService emailService;


    //회원가입
    @PostMapping("/signup")
    public ResponseEntity<MemberResDto> signup(@RequestBody MemberReqDto requestDto) {
        return ResponseEntity.ok(authService.signup(requestDto));
    }
    //로그인
    @PostMapping("/login")
    public ResponseEntity<TokenDto> login(@RequestBody MemberReqDto requestDto) {
        return ResponseEntity.ok(authService.login(requestDto));
    }
    // 회원 존재 여부 확인
    @GetMapping("/exists/{email}")
    public ResponseEntity<Boolean> memberExists(@PathVariable String email) {
        log.info("email: {}", email);
        boolean isTrue = memberService.isMember(email);
        return ResponseEntity.ok(!isTrue);
    }

 

이제 포스트맨으로 확인 해볼까요?

회원가입

로그인

 

 

이렇게 회원가입후 , 로그인을해보면 정상적으로 토큰 발급되는게 확인이됬습니다 . 

이제 사용하는방법도 알려드리자면 

필요한 컨트롤에 저 acessToken 을 가져와 헤더부분에 Bearer ${acessToken} 값으로 진행해주면  정상 인증처리가되면서 작동이되는것을 볼수있었습니다 .

 

필자는 이제 이렇게 발급된 토큰을 리액트에 사용해보았는데 이것을 LocalStorage에 담아 사용했습니다.

다음에는 jwt를 리엑트에서 axios 로받아와 사용하는 방법을  소개해드릴까합니다 . 

반응형