본문 바로가기

스프링

[Security] Spring JWT 인증 With REST API (1)

시작하기에 앞서

이전에 Spring Security Form Login을 통해 인증 처리를 구현해본바 있다.

이번에는 JWT를 이용해 인증 처리를 진행해보려고 한다.

이번엔 REST API를 활용해 간단한 로그인을 구현해보려고 한다.

요구사항

어떤 사용자는 어떤 페이지에 접근하기 위해서 로그인이 꼭 필요하다.

이를 위해 이전에 회원가입을 진행하고 로그인을 진행한 뒤에 해당 페이지에 접근한다.

로그인이 되어 있지 않을 시, 해당 페이지로의 접근은 불가하다.

개발 환경

  • Spring Boot : 2.5.6
  • Spring Security : 5.6.0
  • Gradle : 7.2
  • Mysql : 8.0

Entity

이번 요구사항에서는 단순 회원가입 후 로그인을 진행하는 것이기에 엔티티는 회원으로 간단하게 구성하였다.

역할 같은 경우에 이번에 사용하지는 않지만 추후에 사용할 것을 대비하여 MEMBER(일반 회원), ADMIN(관리자) 두 개로 구성해보았다.

회원

@Entity
@Getter
@Table(name = "MEMBERS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String email;
    private String password;

    @ElementCollection(fetch = FetchType.LAZY)
    @Enumerated(EnumType.STRING)
    private List<Role> roles = new ArrayList<>();

    @Builder
    public Member(String email, String password, List<Role> roles) {
        this.email = email;
        this.password = password;
        this.roles = Collections.singletonList(Role.ROLE_MEMBER);
    }

    public void addRole(Role role) {
        this.roles.add(role);
    }
}
  • Mysql을 사용하기에 IDENTITY 전략을 활용해 ID를 생성한다.
  • 회원은 Email(아이디), Password(비밀번호)를 가진다.
  • 회원은 권한을 가진다.

역할

public enum Role {
    ROLE_MEMBER, ROLE_ADMIN
}
  • 권한은 MEMBER, ADMIN으로 구성하였다.

Repository

Repository는 간단하게 Spring Data JPA를 활용하여 진행해보았다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}

Service

서비스에는 간단하게 회원가입 로직, 로그인 로직을 진행해주었다.

Password의 경우 PasswordEncoder를 활용하여 암호화된 값을 DB에 저장해줄 수 있도록 하였으며, 아이디의 값은 중복된 값을 가질 수 있으므로 이에 대한 검증을 서버단에서 진행할 수 있도록 해주었다.

로그인 부분에 대해서 입력한 비밀번호가 틀리다면, Exception을 발생시켜주었고 아니라면 JWT 토큰을 발행해주었다.

이 부분에 대해서는 뒤에서 자세히 살펴보자.

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

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public MemberRegisterResponseDto registerMember(MemberRegisterRequestDto requestDto) {
        validateDuplicated(requestDto.getEmail());
        Member user = memberRepository.save(
                Member.builder()
                        .email(requestDto.getEmail())
                        .password(passwordEncoder.encode(requestDto.getPassword()))
                        .build());
        return new MemberRegisterResponseDto(user.getId(), user.getEmail());
    }

    /**
     * Unique한 값을 가져야하나, 중복된 값을 가질 경우를 검증
     * @param email
     */
    public void validateDuplicated(String email) {
        if (memberRepository.findByEmail(email).isPresent())
            throw new MemberEmailAlreadyExistsException();
    }

    public MemberLoginResponseDto loginMember(MemberLoginRequestDto requestDto) {
        Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(LoginFailureException::new);
        if (!passwordEncoder.matches(requestDto.getPassword(), member.getPassword()))
            throw new LoginFailureException();
        return new MemberLoginResponseDto(member.getId(), jwtTokenProvider.createToken(requestDto.getEmail()));

    }
}
  • reigsterMember - 회원가입
  • validateDuplicated - 아이디 중복 확인
  • loginMember - 로그인

JWT 토큰 처리

이제 JWT 토큰을 통해 어떻게 진행되는지 살펴보고자 한다.

간단히 살펴보면 다음과 같은 로직이 이루어져야 한다.

  1. 처음 접속하는 사용자는 원하는 기능을 사용하기 위해 페이지에 접속한다.
  2. 해당 기능을 사용하기 위해서는 로그인이 필요하기에 로그인 요청을 한다.
  3. 로그인을 한 사용자는 Access Token을 발행받는다.
  4. 다시 해당 페이지에 접속하면 Access Token을 통해 추후에도 인증을 받아 서비스를 제공받을 수 있다.

  1. 이전에 인증을 받은 사용자는 Access Token을 소유하고 있으므로 서버에 서비스를 요청할 때 같이 보낸다.
  2. JWT 토큰이 제대로된 토큰인지 확인하고 서비스를 제공해준다.

이 부분을 어떻게 구현해야할까?

바로 인증을 진행하기 전에 확인해주면 될 것이다.

그래서 아래 그림처럼 다음 부분에 이를 진행하면 된다.

실제로 인증을 진행하는 AuthenticationFilter 이전에 JWT Token을 검증해주는 JwtAuthenticationFilter 만들어 이를 확인해주면 될 것이다.

JWT 토큰 자체를 검증해주는 것은 필터를 통해 진행한다고 하면 토큰을 실제로 발행하는 것은 어떻게 진행하는가에 대해서는 JwtTokenProvider라는 클래스를 만들어 로그인을 진행하면 해당 클래스에서 Access Token을 발행할 수 있도록 하면 될 것이다.

이제 실제로 이 부분을 구현해보도록 하자.

JwtTokenProvider

먼저, Access Token을 발행해줄 수 있는 Provider를 만들어보자.

Access Token은 Header와 Payload의 값을 각각 Base64로 인코딩한 후 인코딩 된 값을 Secret Key를 이용해 헤더에서 정의한 알고리즘으로 암호화하고 다시 Base64로 인코딩하여 생성한다.

이를 위해 Secret Key를 생성해주어야 하기에 application.yml 파일에 다음과 같이 Secret Key에 대한 부분을 설정해두었다.

spring:
  jwt:
    secretKey: {Key}
  • {Key} 부분에 원하는 키를 지정했다.

토큰은 무한정으로 사용되면 안되기에 만료 시간을 지정해야된다.

이를 위해 토큰의 만료기간을 정해줄 수 있도록 했다.

private long tokenValidTime = 1000L * 60 * 30; // 30분
  • Millisecond 단위로 지정되기에 다음과 같이 생성해주었다.

실제로 Access Token을 생성하기 위해 다음과 같이 진행한다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'
  • JWT를 이용할 수 있도록 Gradle에서 다음과 같은 의존성을 추가한다.

이제 토큰을 생성하는 로직을 살펴보자.

public String createToken(String email) {
    Claims claims = Jwts.claims().setSubject(email);
    Date now = new Date();

    return Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(now)
        .setExpiration(new Date(now.getTime() + tokenValidTime))
        .signWith(SignatureAlgorithm.HS256, secretKey)
        .compact();
}
  • 토큰의 키가 되는 Subject를 중복되지 않는 고유한 값인 Email로 지정한다.
  • 만료시간은 지금 시간으로부터 30분을 설정한다.
  • 서명할 때 사용되는 알고리즘은 HS256, 키는 위에서 지정한 값으로 진행한다.

토큰으로 인증 객체(Authentication)을 얻기 위한 메소드이다.

Form Login에서 UserDetailsService를 구현한 구현체인 MemberDetailsService에서 loadUserByUsername 메소드를 구현하여 실제 DB에 저장되어 있는 회원 객체를 끌고와 인증처리를 진행했던 것을 확인할 수 있었다.

이처럼 우리는 이미 토큰을 통해 이미 인증된 객체를 memberDetailsService에서 불러와 인증객체를 얻어준다.

public Authentication getAuthentication(String token) {
    UserDetails userDetails = memberDetailsService.loadUserByUsername(getMemberEmail(token));
    return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getMemberEmail(String token) {
    try {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    } catch(ExpiredJwtException e) {
        return e.getClaims().getSubject();
    }
}
  • 이메일을 얻기 위해 실제로 토큰을 디코딩하는 부분이다.
  • 지정한 Secret Key를 통해 서명된 JWT를 해석하여 Subject를 끌고와 리턴하여 이를 통해 인증 객체를 끌고올 수 있다.

토큰이 만료되었는 지를 확인해주는 메소드이다.

이전과 같이 token을 디코딩하여 만료시간을 끌고와 현재시간과 비교해 확인해준다.

public boolean validateTokenExpiration(String token) {
    try {
        Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
        return true;
    } catch(Exception e) {
        return false;
    }
}

토큰은 HTTP Header에 저장되어 계속적으로 이용되어진다.

토큰을 사용하기 위해 실제로 Header에서 꺼내오는 메소드이다.

public String resolveToken(HttpServletRequest req) {
    return req.getHeader("X-AUTH-TOKEN");
}

전체 코드

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

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

    private long tokenValidTime = 1000L * 60 * 30; // 30분

    private final UserDetailsService memberDetailsService;

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

    public String createToken(String email) {
        Claims claims = Jwts.claims().setSubject(email);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = memberDetailsService.loadUserByUsername(getMemberEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getMemberEmail(String token) {
        try {
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        } catch(ExpiredJwtException e) {
            return e.getClaims().getSubject();
        }
    }

    public String resolveToken(HttpServletRequest req) {
        return req.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateTokenExceptExpiration(String token) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch(Exception e) {
            return false;
        }
    }
}

토큰 발급 구간

public MemberLoginResponseDto loginMember(MemberLoginRequestDto requestDto) {
    Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(LoginFailureException::new);
    if (!passwordEncoder.matches(requestDto.getPassword(), member.getPassword()))
        throw new LoginFailureException();
    return new MemberLoginResponseDto(member.getId(), jwtTokenProvider.createToken(requestDto.getEmail()));
}
  • 로그인을 진행한 후 리턴할 때 토큰을 만들어 반환하는 모습을 확인할 수 있다.
  • 서버에서 헤더에 토큰을 실어보낼 수 있지만, 프론트에 토큰을 넘겨주어 프론트에서 헤더에 담는 방식을 선택하였다.

JwtAuthenticationFilter

실제로 Provider를 통해 토큰을 발급 받는 모습을 확인할 수 있었다.

우리는 발급받은 토큰을 기반으로 이를 처리해주는 필터를 구성해야하는데 이는 다음과 같다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {

    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.validateTokenExpiration(token)) {
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }
}
  • JwtTokenProvider를 주입받아 헤더에서 토큰을 추출한다.
  • 토큰이 존재하는지 확인하고 존재한다면 만료시간이 지나지 않았는지 확인한다.
  • 성공했다면, 인증 객체를 받아오고 SecurityContextHolder에 저장하여 인증을 할 수 있도록 한다.
  • 그리고 doFilter 메소드를 통해 다음 필터로 넘어가 실제 AuthenticationFilter에서 이미 인증되어 있는 객체를 통해 인증이 되게 된다.

SecurityConfig

이제 실제로 구성한 부분들을 적용시키는 부분이다.

우리는 REST API를 활용하므로 httpBasic, csrf, formlogin, 세션을 모두 꺼준다.

그리고 Security Configuration에 우리가 만든 JwtAuthenticationFilter를 AuthenticationFilter의 구현체인 UsernameAuthenticationFilter 앞단에 위치시켜 이를 검증할 수 있도록 한다.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable() // rest api이므로 기본설정 미사용
            .csrf().disable() // rest api이므로 csrf 보안 미사용
            .formLogin().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt로 인증하므로 세션 미사용
            .and()
                .authorizeRequests()
                .antMatchers("/sign/**").permitAll()
                .antMatchers("/social/**").permitAll()
                .antMatchers("/exception/**").permitAll()
                .anyRequest().authenticated()
            .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
            .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
            .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // jwt 필터 추가
    }
}
  • AuthenticationEntryPoint와 AccessDeniedHandler의 경우 앞서 Form Login에서 살펴봤던 것처럼 인증이 되지 않은 채로 인증이 필요한 페이지에 접근했을 시와 권한이 없는데 권한이 필요한 페이지에 접근했을 때 발생하는 Exception에 대한 처리를 해주는 부분이다.
  • 회원가입과 로그인을 진행하는 /social/과 예외를 처리하는 /exception/에 대해서는 모두 허용한다.
  • 우리가 접속하려는 페이지인 /hello에 대해서는 인증이 필요하다.

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/sign")
public class SignController {

    private final SignService signService;
    private final ResponseService responseService;

    @PostMapping("/register")
    public SingleResult<MemberRegisterResponseDto> register(@RequestBody MemberRegisterRequestDto requestDto) {
        MemberRegisterResponseDto responseDto = signService.registerMember(requestDto);
        return responseService.getSingleResult(responseDto);
    }

    @PostMapping("/login")
    public SingleResult<MemberLoginResponseDto> login(@RequestBody MemberLoginRequestDto requestDto) {
        MemberLoginResponseDto responseDto = signService.loginMember(requestDto);
        return responseService.getSingleResult(responseDto);
    }
}
  • 회원가입과 로그인을 위한 처리를 진행한다.
@RestController
public class HomeController {
    @GetMapping("/hello")
    public String hello() {
        return "Hello";
    }
}
  • 단순하게 Hello 문자열을 반환하는 처리를 진행한다.

테스트

이 부분을 테스트해보기 위해 Postman을 활용하였다.

먼저, 회원가입을 진행해보자.

성공적으로 회원가입이 된 모습을 확인할 수 있다.

다음으로 로그인을 진행해보자.

로그인을 진행하게 되면 로그인된 아이디의 PK와 Access Token을 받는다.

우리는 이 토큰을 헤더에 담아 인증 처리를 진행할 수 있다.

실제로 이 부분이 처리되는지 확인해보자.

먼저, 토큰을 담지 않고 /hello 페이지를 요청했을 때를 보자.

Exception이 발생해 인증이 필요하다는 Json을 받는다.

이제 실제로 토큰을 담아 요청을 해보자.

실제로 인증 처리가 진행되고 해당 페이지에 접근할 수 있음을 확인할 수 있다.

마무리

우리는 실제로 기본적인 Local Login에 대해 JWT를 활용해보았다.

지금은 단순히 Access Token만을 활용하는 부분을 다루지만 다음 포스트에서 Refresh Token을 이용해 효율적으로 관리하는 방법을 알아볼 예정이다.