CS 공부 & 기초 지식

[Spring Boot] JWT를 이용한 인증/인가 구현하기(Spring Security X)

Coding-Su 2024. 11. 15. 20:05
728x90

JWT를 사용하기 위한 파일

FilterConfig.java

@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JwtFilter(jwtUtil));
        registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다.

        return registrationBean;
    }
}

우선 jwt를 필터에서 config 파일을 등록해줍니다.

 

JwtFilter.java

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;
    private final Pattern authPattern = Pattern.compile("^/v\\d+/auth.*");

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        // `/v{숫자}/auth`로 시작하는 URL은 필터를 통과하지 않도록 설정
        if (authPattern.matcher(url).matches()) {
            chain.doFilter(request, response);
            return;
        }

        // NOTE: 위의 방법이 이해가 어려운 분은 이런 방법을 사용하셔도 좋습니다.
//        if (url.startsWith("/v1/auth") || url.startsWith("/v2/auth")) {
//            chain.doFilter(request, response);
//            return;
//        }

        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null || !bearerJwt.startsWith("Bearer ")) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);

            // 사용자 정보를 ArgumentResolver 로 넘기기 위해 HttpServletRequest 에 세팅
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email", String.class));

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("JWT 토큰 검증 중 오류가 발생했습니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰 검증 중 오류가 발생했습니다.");
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

JWT 필터를 등록합니다. 

 

	if (authPattern.matcher(url).matches()) {
            chain.doFilter(request, response);
            return;
        }

위 코드 부분은 doFilter에서 검증을 하지 않고 넘어가기 위해 등록을 합니다. 

로그인과 회원가입의 경우 필터를 통과하지 않고 그냥 지나간다고 명시해 주는 과정입니다.

만약 하지 않는다면 로그인과 회원가입을 하기 위해 JWT토큰이 필요하다는 에러가 나타나게 됩니다.

 

JwtUtil.java

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}")
    private String secretKey;

    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String username, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("username", username)
                        .claim("email", email)
                        .claim("userRole", userRole.name())
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        log.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

위 코드에서는 JWT 토큰을 생성해서 발급해주는 코드입니다.

"${jwt.secret.key}"는 application.yaml이나 application.properties에서 설정해주시면 됩니다.

 

3개의 파일을 모두 작성하였다면 기본적으로 JWT를 사용할 준비가 끝났습니다.

하지만 저희는 로그인에서 사용할 예정임으로 몇가지 더 코드를 추가하겠습니다.

이번 로그인에서는 @Auth 어노테이션 방식으로 사용할 예정입니다.

 

추가적인 코드

비밀번호 암호화

@Component
public class PasswordEncoder {

    /**
     * 원문 비밀번호를 BCrypt 알고리즘을 사용하여 암호화합니다.
     * @param rawPassword 원문 비밀번호
     * @return 암호화된 비밀번호 문자열
     */
    public String encode(String rawPassword) {
        // BCrypt의 기본 설정을 사용하여 비밀번호를 해시화
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    /**
     * 원문 비밀번호와 암호화된 비밀번호를 비교하여 일치 여부를 확인합니다.
     * @param rawPassword 원문 비밀번호
     * @param encodedPassword 암호화된 비밀번호
     * @return 비밀번호 일치 여부 (true: 일치, false: 불일치)
     */
    public boolean matches(String rawPassword, String encodedPassword) {
        // BCrypt를 사용하여 원문 비밀번호와 암호화된 비밀번호를 비교
        BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
        return result.verified; // 비교 결과 반환
    }
}

회원가입 할때 비밀번호를 바로 DB에 저장하면 보안성에 취약해지기 때문에 원문 비밀번호를 암호화 해야합니다. 

 

Auth.java

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}

로그인 과정에서 사용할 어노테이션을 생성해줍니다. 이 어노테이션의 경우 커스텀 어노테이션이기 때문에 원하는 이름으로 설정할 수 있습니다.

 

AuthUserArgumentResolver.java

public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    // @Auth 어노테이션이 있는지 확인
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(Auth.class) != null;
    }

    // AuthUser 객체를 생성하여 반환
    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");

        return new AuthUser(userId, email); // userid 와 email를 담기 위한 객체를 생성함
    }
}

 

ArgumentResolver를 생성해줍니다. ArgumentResolver를 통해 유저의 정보를 가져올 수 있습니다. 즉, 로그인 할때 유저의 정보를 가져오기 위해 필요합니다.

 

WebConfig.java

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}

마지막으로 ArgumentResolver를 사용하기 위해 WebConfig에 등록을 해줍니다.

 

이제 로그인 기능을 위한 준비는 모두 끝났습니다.

 

로그인, 회원가입 구현하기

AuthController.java

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

    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<SiginupResponseDto> signup(@RequestBody SiginupRequestDto siginupRequestDto) {
        return ResponseEntity.ok(authService.signup(siginupRequestDto));
    }

    @PostMapping("/signin")
    public ResponseEntity<SigininResponseDto> siginin(@RequestBody SigininRequestDto sigininRequestDto) {
        String token = authService.signin(sigininRequestDto).getBearerToken();
        return ResponseEntity.ok().header("Authorization",token).body(authService.signin(sigininRequestDto));
    }
}

AuthService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    @Transactional
    public SiginupResponseDto signup(SiginupRequestDto siginupRequestDto) {

        if(userRepository.existsByEmail(siginupRequestDto.getEmail())) {
            throw new IllegalArgumentException("이미 존재하는 이메일 입니다.");
        }

        String encodedPassword = passwordEncoder.encode(siginupRequestDto.getPassword());

        User newUser = new User(siginupRequestDto.getName(), siginupRequestDto.getEmail(), encodedPassword);

        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(
                savedUser.getId(),
                savedUser.getName(),
                savedUser.getEmail(),
                savedUser.getRole()
        );

        return new SiginupResponseDto(bearerToken);
    }

    public SigininResponseDto signin(SigininRequestDto sigininRequestDto) {
        User user = userRepository.findByEmail(sigininRequestDto.getEmail()).orElseThrow(
                () -> new IllegalArgumentException("이메일을 찾을 수 없습니다.")
        );

        if (!passwordEncoder.matches(sigininRequestDto.getPassword(), user.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호 입니다.");
        }

        String bearerToken = jwtUtil.createToken(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getRole()
        );

        return new SigininResponseDto(bearerToken);
    }
}

 

AuthController와 AuthService에서 로그인과 회원가입을 구현하였습니다. 

Controller에서 RequestMapping을 보면 "/v1/auth"로 시작하는 것을 알 수 있습니다. 따라서 Filter를 넘어가기 때문에 JWT토큰이 없이 바로 로그인이나 회원가입을 할 수 있습니다.

 

로그인을 하게 되면 JWT토큰을 반환합니다. 현재 방법은 body와 header 모두에서 반환을 하는데 만약 header에서만 반환을 하고 싶다면 body부분을 지우고 사용할 수 있습니다.

 

AuthService에서는 우선 회원가입 할 때 Id중복을 확인하고 비밀번호를 인코딩 한 다음 저장합니다.

로그인을 할 때는 회원이 가입되어 있는지 확인을 하고, 비밀번호를 확인한 뒤에 토큰을 생성해서 발급해줍니다.

 

@Auth 사용하기

MemoController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/memos")
public class MemoController {

    private final MemoService memoService;

    @PostMapping
    public ResponseEntity<MemoResponseDto> createMemo(@Auth AuthUser authUser, @RequestBody MemoRequsetDto memoRequsetDto) {
        MemoResponseDto memoResponseDto = memoService.createMemo(authUser, memoRequsetDto);
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDto);
    }

    @GetMapping
    public ResponseEntity<List<MemoResponseDto>> getMemo() {
        List<MemoResponseDto> memoResponseDtoList = memoService.getMemo();
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDtoList);
    }

    @PatchMapping("/{memoId}")
    public ResponseEntity<MemoResponseDto> patchMemo(@PathVariable("memoId") Long memoId, @RequestBody MemoRequsetDto memoRequsetDto) {
        MemoResponseDto memoResponseDto = memoService.patchMemo(memoId, memoRequsetDto);
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDto);
    }

    @DeleteMapping("/{memoId}")
    public void deleteMemo(@PathVariable("memoId") Long memoId) {
        memoService.deleteMemo(memoId);
    }

}

 

MemoService.java

@Service
@RequiredArgsConstructor
public class MemoService {

    private final MemoRepository memoRepository;
    private final UserRepository userRepository;

    public MemoResponseDto createMemo(AuthUser authUser, MemoRequsetDto memoRequsetDto) {
        User findUser = userRepository.findById(authUser.getId()).orElseThrow(
                ()-> new IllegalArgumentException("유저를 찾을 수 없습니다."));

        Memo memo = new Memo(findUser.getName(), memoRequsetDto);
        return new MemoResponseDto(memoRepository.save(memo));
    }

    public List<MemoResponseDto> getMemo() {
        return memoRepository.findAll().stream().map(MemoResponseDto::new).toList();
    }

    @Transactional
    public MemoResponseDto patchMemo(Long memoId, MemoRequsetDto memoRequsetDto) {
        Memo findMemo = memoRepository.findById(memoId).orElseThrow();
        return new MemoResponseDto(findMemo.patchMemo(memoRequsetDto));
    }

    public void deleteMemo(Long memoId) {
        memoRepository.delete(memoRepository.findById(memoId).orElseThrow());
    }
}

 

메모 CRUD를 간단하게 만들었습니다.

@Auth 어노테이션은 위와 같이 유저의 정보를 가져올 수 있습니다.

 

메모를 생성할 때 누가 작성하였는지 저장을 하고, 조회나 수정은 누구나 할 수 있지만 삭제의 경우 글을 작성한 사람이 맞는지 확인을 하고 삭제할 수 있도록 구현이 되어있습니다.

 

 

JWT를 이용한 로그인 구현, 메모 구현에 관한 코드입니다.

https://github.com/SuHyun-git/spring-basecode/tree/study/jwt

 

GitHub - SuHyun-git/spring-basecode

Contribute to SuHyun-git/spring-basecode development by creating an account on GitHub.

github.com

 

728x90