스프링부트, JWT 구현

작성일

스프링 시큐리티 + Jwt 를 구현해보겠다.

  • JWT 인증 로직 구현
  • 패스워드 암호화 로직 구현

Jwt에 대한 자세한 내용은 JWT(Json Web Token) 인증을 참고하자.

실습 전 준비

  • User.java
  • UserRepository.java
  • UserService.java
  • UserDTO.java
  • UserController.java
package me.yessm.userauth.user;

import lombok.*;

import javax.persistence.*;

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "email")})
public class User {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;
}
package me.yessm.userauth.user;

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

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    User findByEmail(String email);
    Boolean existsByEmail(String email);
    User findByEmailAndPassword(String email, String password);
}
package me.yessm.userauth.user;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public User create(final User user) {
        if (user == null || user.getEmail() == null) {
            throw new RuntimeException("Invalid arguments");
        }

        final String email = user.getEmail();
        if (userRepository.existsByEmail(email)) {
            throw new RuntimeException("Email already exists");
        }

        return userRepository.save(user);
    }

    public User getByCredentials(final String email, final String password) {
        return userRepository.findByEmailAndPassword(email, password);
    }
}
package me.yessm.userauth.user;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {

    private String token;
    private String email;
    private String username;
    private String password;
    private Long id;
}
package me.yessm.userauth.user;

import me.yessm.userauth.common.ResponseDTO;
import me.yessm.userauth.security.TokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
        try {
            User user = User.builder()
                    .email(userDTO.getEmail())
                    .username(userDTO.getUsername())
                    .password(userDTO.getPassword())
                    .build();

            User registeredUser = userService.create(user);
            UserDTO responseUserDTO = UserDTO.builder()
                    .email(registeredUser.getEmail())
                    .id(registeredUser.getId())
                    .username(registeredUser.getUsername())
                    .build();

            return ResponseEntity.ok().body(responseUserDTO);
        } catch (Exception e) {
            ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();

            return ResponseEntity.badRequest().body(responseDTO);
        }
    }

    @PostMapping("/signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
        User user = userService.getByCredentials(
                userDTO.getEmail(),
                userDTO.getPassword());

        if (user != null) {
            final UserDTO responseUserDTO = UserDTO.builder()
                    .email(user.getUsername())
                    .id(user.getId())
                    .build();

            return ResponseEntity.ok().body(responseUserDTO);
        } else {
            ResponseDTO responseDTO = ResponseDTO.builder()
                    .error("Login failed")
                    .build();

            return ResponseEntity.badRequest().body(responseDTO);
        }
    }
}

JWT 생성 및 반환 구현

사용자 정보를 바탕으로 헤더와 페이로드를 작성학고 전자 서명한 후 토큰을 리턴

1st

1. JWT 관련 라이브러리 추가

implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

2. TokenProvider 생성

package me.yessm.userauth.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import me.yessm.userauth.user.User;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;

@Service
public class TokenProvider {

    private static final String SECRET_KEY = "test12341234";

    public String create(User user) {
        Date expiryDate = Date.from(Instant.now().plus(1, ChronoUnit.DAYS));

        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
                .setSubject(user.getEmail())
                .setIssuer("yessm app")
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .compact();
    }

    public String validateAndGetUserId(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }
}

TokenProvider 클래스가 하는 일은 사용자 정보를 받아 JWT를 생성하는 일

create(): JWT 라이브러리를 이용해 JWT 토큰을 생성

validateAndGetUserId(): 토큰을 디코딩 및 파싱하고 토큰의 위조 여부 확인

3. UserController.java 수정

    @PostMapping("/signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
        User user = userService.getByCredentials(
                userDTO.getEmail(),
                userDTO.getPassword());

        if (user != null) {
            // 토큰 생성, UserDTO에 추가
            final String token = tokenProvider.create(user);
            final UserDTO responseUserDTO = UserDTO.builder()
                    .email(user.getUsername())
                    .id(user.getId())
                    .token(token)
                    .build();

            return ResponseEntity.ok().body(responseUserDTO);
        } else {
            ResponseDTO responseDTO = ResponseDTO.builder()
                    .error("Login failed")
                    .build();

            return ResponseEntity.badRequest().body(responseDTO);
        }
    }

4. 테스트

Untitled1

Untitled2

인코딩된 JWT 토큰

{
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ5ZXNzbTYyM0BnbWFpbC5jb20iLCJpc3MiOiJ5ZXNzbSBhcHAiLCJpYXQiOjE2NTAzNDU3NjEsImV4cCI6MTY1MDQzMjE2MX0.znuDrNcPA6BcKUzbysOQJ99vxm_OCTPpEhr1DUfveMjkkDh1uEKn4zF7pHSBiz3cTR4gBRalyKMDUHgn3RSIHA",
    "email": "user1",
    "username": null,
    "password": null,
    "id": 3
}

스프링 시큐리티와 서블릿 필터

2nd

스프링 시큐리티의 도움을 받아 API가 실행될 때마다 사용자를 인증해 주는 부분을 구현해야 한다. 토큰 인증을 위해 컨트롤러 메서드의 첫 부분마다 인증 코드를 작성하기 위해 서블릿 필터를 사용한다.

스프링 시큐리티는 서블릿 필터의 집합이다. 서블릿 필터란 서블릿 실행 전에 실행되는 클래스들이다. 스프링이 구현하는 서블릿이 디스패처 서블릿이고 서블릿 필터는 디스패처 서블릿이 실행되기 전에 항상 실행된다. 따라서, 개발자는 서블릿 필터를 구현하고 서블릿 필터를 서블릿 컨테이너가 실행하도록 설정해 주기만 하면 된다.

서블릿 필터는 구현된 로직에 따라 원하지 않는 HTTP 요청을 걸러낼 수 있다. 걸러낸 HTTP는 거절되는 것이고 서블릿 필터가 전부 살아남은 HTTP요청은 디스패처 서블릿으로 넘어와 컨트롤러에서 실행된다.

JWT를 이용한 인증 구현

스프링 시큐리티관련 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-security'

OncePerRequestFilter 클래스를 상속해 필터를 생성한다. OncePerRequestFilter는 한 요청당 반드시 한 번만 실행된다.

OncePerRequestFilter를 상속받는 JwtAuthenticationFilter를 구현

package me.yessm.userauth.security;

import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

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

@Log4j2
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 요청에서 토큰 가져옴
            String token = parseBearerToken(request);

            if (token != null && !token.equalsIgnoreCase("null")) {
                // userId 가져오기, 위조된 경우 예외 처리
                String userId = tokenProvider.validateAndGetUserId(token);

                // 인증 완료. SecurityContextHolder에 등록해야 인증된 사용자라고 생각한다
                AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, AuthorityUtils.NO_AUTHORITIES);

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authentication);
                SecurityContextHolder.setContext(securityContext);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }
        filterChain.doFilter(request, response);
    }

    private String parseBearerToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

위 코드의 처리 과정

  1. 요청의 헤더에서 Bearer 토큰을 가져옴
  2. TokenProvider를 이용해 토큰을 인증하고 UsernamePasswordAuthenticationToken 작성

    → UsernamePasswordAuthenticationToken 오브젝트에 사용자의 인증 정보를 저장하고 SecurityContext에 인증된 사용자를 등록

    → 등록하는 이유? 요청을 처리하는 과정에서 사용자가 인증됐는지의 여부나 인증된 사용자가 누군지 알아야 할 때가 있기 때문

스프링 시큐리티의 SercurityContext는 SecurityContextHolder의 createEmptyContext() 메서드를 이용해 생성이 가능하다.

생성한 컨텍스트에 인증 정보인 authentication을 넣고 다시 SecurityContextHolder에 컨텍스트로 등록한다.

SecurityContextHolder는 기본적으로 ThreadLocal에 저장된다.

ThreadLocal에 저장되므로 Thread마다 하나의 컨텍스트를 관리할 수 있다.

스프링 시큐리티 설정

서블릿 필터를 사용하려면

  1. 서블릿 필터를 구현해야 함
  2. 서블릿 컨테이너에 이 서블릿 필터를 사용하라고 알려주는 설정 작업

스프링 시큐리티에 JwtAuthenticationFilter를 사용하라고 알려주자.

package me.yessm.userauth.config;

import me.yessm.userauth.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
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.web.filter.CorsFilter;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors()
                .and()
                .csrf()
                .disable()
                .httpBasic()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/", "/auth/**").permitAll()
                .anyRequest()
                .authenticated();

        http.addFilterAfter(
                jwtAuthenticationFilter,
                CorsFilter.class
        );
    }
}

HttpSecurity 는 시큐리티 설정을 위한 오브젝트

web.xml 대신 HttpSecurity 를 이용해 시큐리티 관련 설정을 하는 것

addFilterAfter() 메서드를 통해 CorsFilter 이후에 jwtAuthenticationFilter 실행하게 설정

테스팅

  1. 회원가입 후 다시 로그인을 하면 아래 그림과 같이 응답과 함께 토큰이 온다

Untitled3

  1. Authorization 에 Bearer Token 을 선택하고 토큰을 복사하여 붙여넣기 한다

Untitled4

  1. 토큰이 위조되면 아래와 같은 에러메시지가 발생한다.
// JWT를 신뢰할 수 없어 예외 처리됨
io.jsonwebtoken.SignatureException: 
JWT signature does not match locally computed signature. 
JWT validity cannot be asserted and should not be trusted.

TodoController에서 인증된 사용자 사용하기

package me.yessm.userauth.controller;

import me.yessm.userauth.dto.ResponseDTO;
import me.yessm.userauth.dto.TodoDTO;
import me.yessm.userauth.entity.Todo;
import me.yessm.userauth.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

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

@RestController
@RequestMapping("todo")
public class TodoController {

    @Autowired
    private TodoService todoService;

    @GetMapping("/test")
    public ResponseEntity<?> testTodo() {
        String str = todoService.testService();
        List<String> list = new ArrayList<>();
        list.add(str);
        ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();

        return ResponseEntity.ok(response);
    }

    @PostMapping
    public ResponseEntity<?> createTodo(
            @AuthenticationPrincipal String userId,
            @RequestBody TodoDTO dto) {
        try {
            Todo entity = TodoDTO.toEntity(dto);
            entity.setId(null);
            entity.setUserId(userId);

            List<Todo> entities = todoService.create(entity);
            List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());

            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            String error = e.getMessage();
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();

            return ResponseEntity.badRequest().body(response);
        }
    }

    @GetMapping
    public ResponseEntity<?> retrieveTodoList(
            @AuthenticationPrincipal String userId) {
        System.out.println("UserID : " + userId);
        List<Todo> entities = todoService.retrieve(userId);

        List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());

        ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();

        return ResponseEntity.ok(response);
    }

    @PutMapping
    public ResponseEntity<?> updateTodo(@AuthenticationPrincipal String userId,
                                        @RequestBody TodoDTO dto) {
        Todo entity = TodoDTO.toEntity(dto);
        entity.setUserId(userId);
        List<Todo> entities = todoService.update(entity);

        List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());

        ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();

        return ResponseEntity.ok(response);
    }

    @DeleteMapping
    public ResponseEntity<?> deleteTodo(
            @AuthenticationPrincipal String userId,
            @RequestBody TodoDTO dto) {
        try {
            Todo entity = TodoDTO.toEntity(dto);
            entity.setUserId(userId);
            List<Todo> entities = todoService.delete(entity);

            List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());

            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            String error = e.getMessage();
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();

            return ResponseEntity.badRequest().body(response);
        }
    }
}

매개변수 userId는 스프링의 @AuthenticationPrincipal를 이용해서 찾는다.

@AuthenticationPrincipal 이란?

AbstractAuthenticationToken authentication = 
new UsernamePasswordAuthenticationToken(userId, null, AuthorityUtils.NO_AUTHORITIES);

UsernamePasswordAuthenticationToken 생성자의 첫 매개변수가 AuthenticationPrincipal 이다

패스워드 암호화

스프링 시큐리티가 제공하는 BCryptPasswordEncoder 를 사용

public User getByCredentials(final String email, final String password, final PasswordEncoder encoder) {
    final User originalUser = userRepository.findByEmail(email);

    if (originalUser != null && encoder.matches(password, originalUser.getPassword())) {
        return originalUser
    }
    return userRepository.findByEmailAndPassword(email, password);
}

BCryptPasswordEncoder 는 같은 값을 인코딩해도 할 때마다 값이 다르기 때문에 matches() 메서드를 이용해 패스워드가 같은지 비교

package me.yessm.userauth.controller;

import me.yessm.userauth.dto.ResponseDTO;
import me.yessm.userauth.dto.UserDTO;
import me.yessm.userauth.entity.User;
import me.yessm.userauth.security.TokenProvider;
import me.yessm.userauth.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class UserController {

    @Autowired
    private UserService userService;

    @Autowired
    private TokenProvider tokenProvider;

    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
        try {
            User user = User.builder()
                    .email(userDTO.getEmail())
                    .username(userDTO.getUsername())
                    .password(passwordEncoder.encode(userDTO.getPassword()))
                    .build();

            User registeredUser = userService.create(user);
            UserDTO responseUserDTO = UserDTO.builder()
                    .email(registeredUser.getEmail())
                    .id(registeredUser.getId())
                    .username(registeredUser.getUsername())
                    .build();

            return ResponseEntity.ok().body(responseUserDTO);
        } catch (Exception e) {
            ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();

            return ResponseEntity.badRequest().body(responseDTO);
        }
    }

    @PostMapping("/signin")
    public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
        System.out.println("signin userDTO: " + userDTO);
        User user = userService.getByCredentials(
                userDTO.getEmail(),
                userDTO.getPassword(),
                passwordEncoder);

        if (user != null) {
            // 토큰 생성
            final String token = tokenProvider.create(user);
            final UserDTO responseUserDTO = UserDTO.builder()
                    .email(user.getUsername())
                    .id(user.getId())
                    .token(token)
                    .build();

            return ResponseEntity.ok().body(responseUserDTO);
        } else {
            ResponseDTO responseDTO = ResponseDTO.builder()
                    .error("Login failed")
                    .build();

            return ResponseEntity.badRequest().body(responseDTO);
        }
    }
}