티스토리 뷰

지난 시간에 회원가입까지 진행했다면 이제 회원가입한 계정으로 로그인을 진행하도록 한다.

JWT는 유저를 인증하고 식별하기 위한 토큰기반 인증이다. 토큰은 세션과는 달리 서버가 아닌 클라이언트에 저장되기 때문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다. JWT 가 가지는 핵심적인 특징이 있다면, 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함된다는 것이다.


더 자세하게 알고 싶다면 아래의 벨로그가 도움이 될 것이다.

🤔 JWT, 정확하게 무엇이고 왜 쓰이는 걸까?

Json Web Token에 대해서 정확히 알고 넘어가자.

velog.io


JWT+ Spring Security 전체적인 동작 과정

Security + JWT 기본 동작 원리 [출처: gksdudrb922.tistory.com/217]

Refresh Token?

  • Access Token을 발급하기 위한 토큰이다.
  • Refresh Token의 유효기간은 Access Token의 유효기간보다 길게 설정해야 한다.
    • 기본적으로 Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정
    • 유효기간이 끝난 Access Token에 대해 Refresh Token을 사용하여 새로운 Access Token을 발급

Refresh Token이 유출되어서 다른 사용자가 이를 통해 새로운 Access Token을 발급받았다면?

이 경우, Access Token의 충돌이 발생하기 때문에, 서버측에서는 두 토큰을 모두 폐기시켜야 한다.
국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여, 사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있다.

새로운 Access Token + Refresh Token에 대한 재발급 원리

Access Token + Refresh Token 재발급 과정 [출처: gksdudrb922.tistory.com/217]



1. 의존성 추가

(의존성은 지난 게시글과 동일하다 이 이후에는 지난 게시글과 동일한 부분은 언급X)
build.gradle

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

	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

2. TokenInfoResponse.java 작성

클라이언트에게 보내는 토큰 Dto

@Builder
@Getter
@AllArgsConstructor
public class TokenInfoResponse {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Long refreshTokenExpirationTime;

    public static TokenInfoResponse from(String grantType, String accessToken, String refreshToken, Long refreshTokenExpirationTime) {
        return TokenInfoResponse.builder()
                .grantType(grantType)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(refreshTokenExpirationTime)
                .build();
    }
}

3. JwtTokenProvider.java 작성

application.properties에 다음 설정 추가

jwt.secret=[secrete code]
jwt.header=Authorization
jwt.access-token-validity-in-seconds=[access-token 기한]
jwt.refresh-token-validity-in-seconds=[refresh-token 기한]


코드가 길어서 처음에는 정신나갈 것 같지만 어렵겠지만 JWT 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능이 구현된 클래스이기 때문에 각 메소드가 어떤 역할을 하는지 이해하고 넘어가야 다음 과정이 이해가 간다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements InitializingBean {
    private static final String AUTHORITIES_KEY = "auth";

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

    @Value("${jwt.access-token-validity-in-seconds}")
    private long accessTokenValidityTime;

    @Value("${jwt.refresh-token-validity-in-seconds}")
    private long refreshTokenValidityTime;

    private final UserRepository userRepository;

    private Key key;

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenInfoResponse createToken(Authentication authentication) { //유저 정보를 가지고 AccessToken, RefreshToken 생성
        // 1- 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // 2- Access Token, Refresh Token 기한 설정
        Date accessTokenValidity = new Date(now + this.accessTokenValidityTime);
        Date refreshTokenValidity = new Date(now + this.refreshTokenValidityTime);

		// 3- Access Token 생성
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(accessTokenValidity)
                .compact();

		// 4- Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(refreshTokenValidity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

		// 5- TokenInfoDto에 담아서 반환
        return TokenInfoResponse.from("Bearer", accessToken, refreshToken, refreshTokenValidityTime);

    }

    public Authentication getAuthentication(String accessToken) { // JWT 토큰을 복호화하여 토큰 인증

		// 1- 토큰 복호화
        Claims claims = parseClaims(accessToken); 

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

		// 2- 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities = 
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // 3- claims의 getSubject이용해서 email 가져와 user객체 찾아 AuthenticationToken리턴
        User user = this.userRepository.findByEmail(claims.getSubject()).orElseThrow(NotFoundEmailException::new);
        return new UsernamePasswordAuthenticationToken(new UserDetailsImpl(user), accessToken, authorities);
    }
 
    public boolean validateToken(String token) { // 토큰 정보를 검증
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
            throw e;
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            throw e;
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
            throw e;
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
            throw e;
        }
    }

    public boolean validateRefreshToken(String token) { //RefreshToken 검증
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
            throw e;
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            throw e;
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
            throw e;
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
            throw e;
        } finally {
            return false;
        }
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Long getExpiration(String accessToken) { //만료되었는지 확인
        Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
        Long now = new Date().getTime();
        return (expiration.getTime() - now);
    }
}

4. JwtFilter.java 작성

SecurityConfig.class에서 UsernamePasswordAuthenticationFilter 이전에 통과할 Filter이다. 인증(Authentication)이 필요한 요청(Request)이 오면 요청의 헤더(Header)에서 Access Token을 추출하고 정상 토큰인지 검사한다.

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1- Access Token 추출
        String jwt = resolveToken(request); 
        String requestURI = request.getRequestURI();
        try { // 2- 토큰 유효성 검증
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Authentication authentication = tokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
            }
        } catch (SecurityException | MalformedJwtException e) { // 에러- 잘못된 토큰 서명인 경우
            request.setAttribute("exception", JWTExceptionList.WRONG_TYPE_TOKEN.getErrorCode());
        } catch (ExpiredJwtException e) { // 에러- 만료된 토큰인 경우
            request.setAttribute("exception", JWTExceptionList.EXPIRED_TOKEN.getErrorCode());
        } catch (UnsupportedJwtException e) { // 에러- 지원되지 않은 토큰인 경우
            request.setAttribute("exception", JWTExceptionList.UNSUPPORTED_TOKEN.getErrorCode());
        } catch (IllegalArgumentException e) { // 에러-잘못된 토큰인 경우
            request.setAttribute("exception", JWTExceptionList.WRONG_TOKEN.getErrorCode());
        } catch (Exception e) { // 에러- 그외
            log.error("================================================");
            log.error("JwtFilter - doFilterInternal() 오류발생");
            log.error("token : {}", jwt);
            log.error("Exception Message : {}", e.getMessage());
            log.error("Exception StackTrace : {");
            e.printStackTrace();
            log.error("}");
            log.error("================================================");
            request.setAttribute("exception", JWTExceptionList.UNKNOWN_ERROR.getErrorCode());
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) { // HTTP Request 헤더로부터 토큰 추출
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

5. SecurityConfig.java 수정

지난 게시글에서 작성한 것에서 configure에 addFilterBefore()를 추가한다.
-> 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하기 위함

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .formLogin().disable()
                .httpBasic().disable()
                .headers().frameOptions().sameOrigin()
                .and()
                .authorizeRequests()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/swagger-ui/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v3/api-docs").permitAll()
                .antMatchers("/image/**").permitAll()
                .antMatchers("/user/**").permitAll()
//                .antMatchers("/todos/**").permitAll()
                .anyRequest().authenticated()
                .and() 
                // 추가한 부분
                .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

    }

6. UserDetailServiceImpl.java UserDetailsImpl.java

로그인 처리를 하고 인증된 사용자를 어떤 형태로 반환할지 정하는 객체를 생성한다.
UserDetailServiceImpl.java

@RequiredArgsConstructor
@Service
public class UserDetailServiceImpl implements UserDetailsService {

    private final UserService userService;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new UserDetailsImpl(this.userService.validateEmail(username));
    }
}

UserDetailsImpl.java

@Getter
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(this.user.getRole().name()));
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

7. 서비스에 로그인 내용 추가

UserService.java

public interface UserService {
    SignupResponse singup(SignupRequest signupRequest);
    // 추가한 부분
    LoginResponse login(LoginRequest loginRequest);

    User validateEmail(String email);
}

UserServiceImpl.java

@RequiredArgsConstructor
@Service
@Slf4j
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    public SignupResponse singup(UserDto.SignupRequest signupRequest) {
        this.validateOverlap(signupRequest.getEmail());
        User user = userMapper.toEntity(signupRequest);
        user.encryptPassword(passwordEncoder);
        user.setRole(ROLE_USER);
        this.userRepository.save(user);
        return new SignupResponse(signupRequest.getEmail());
    }

    private void validateOverlap(String email) {
        userRepository.findByEmail(email)
                .ifPresent((m -> {
                    throw new OverlapUserException();
                }));
    }
    
    //추가한 부분
    @Override
    public LoginResponse login(LoginRequest loginRequest) {
        TokenInfoResponse tokenInfoResponse = this.validateLogin(loginRequest);
        return LoginResponse.from(tokenInfoResponse);
    }

    public TokenInfoResponse validateLogin(LoginRequest loginRequest) {
     	// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword());
        
        // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 UserDetailsServiceImpl 에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = this.authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        
        SecurityContextHolder.getContext().setAuthentication(authentication);
        
        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenInfoResponse tokenInfoResponse = this.tokenProvider.createToken(authentication);
        return tokenInfoResponse;
    }

    @Override
    public User validateEmail(String email) {
        return this.userRepository.findByEmail(email).orElseThrow(NotFoundEmailException::new);
    }
}

8. UserDto 추가

로그인, 회원가입할 때 요청, 응답 DTO

public abstract class UserDto {

    @Getter
    @AllArgsConstructor
    @Builder
    @ApiModel(description = "회원가입을 위한 요청객체")
    public static class SignupRequest {
        @NotBlank(message = "이메일을 입력해주세요")
        @ApiModelProperty(notes = "이메일을 입력해주세요")
        private String email;

        @NotBlank(message = "비밀번호를 입력해주세요")
        @ApiModelProperty(notes = "비밀번호를 입력해주세요")
        private String password;

    }

    @Getter
    @Builder
    @ApiModel(description = "회원가입을 위한 응답객체")
    public static class SignupResponse {
        private String email;

        @QueryProjection
        public SignupResponse(String email) {
            this.email = email;
        }
    }

    // 추가한 부분
    @Getter
    @AllArgsConstructor
    @Builder
    @ApiModel(description = "로그인을 위한 요청객체")
    public static class LoginRequest {
        @NotBlank(message = "이메일을 입력해주세요")
        @ApiModelProperty(notes = "이메일을 입력해주세요")
        private String email;

        @NotBlank(message = "비밀번호를 입력해주세요")
        @ApiModelProperty(notes = "비밀번호를 입력해주세요")
        private String password;
    }

    @Getter
    @Builder
    @ApiModel(description = "로그인을 위한 응답객체")
    public static class LoginResponse {
        private String accessToken;
        private String refreshToken;

        public static LoginResponse from(TokenInfoResponse tokenInfoResponse) {
            return LoginResponse.builder()
                    .accessToken(tokenInfoResponse.getAccessToken())
                    .refreshToken(tokenInfoResponse.getRefreshToken())
                    .build();
        }
    }
}

9. UserController 추가

@RequiredArgsConstructor
@RestController
@RequestMapping("user")
@Api(tags = "User API")
public class UserController {
    private final UserService userService;

    @ApiOperation(value = "회원가입", notes = "회원가입을 합니다.")
    @PostMapping("/signup")
    public ResponseEntity<ResponseDto<SignupResponse>> singupUser(@Valid @ModelAttribute SignupRequest signupRequest) {
        return ResponseEntity.ok(ResponseDto.create(UserConstants.EBoardResponseMessage.SIGNUP_SUCCESS.getMessage(), this.userService.singup(signupRequest)));
    }

    // 추가한 부분
    @ApiOperation(value = "로그인", notes = "로그인을 합니다.")
    @PostMapping("/login")
    public ResponseEntity<ResponseDto<LoginResponse>> loginUser(@Valid @ModelAttribute LoginRequest loginRequest) {
        return ResponseEntity.ok(ResponseDto.create(UserConstants.EBoardResponseMessage.LOGIN_SUCCESS.getMessage(), this.userService.login(loginRequest)));
    }
}

10. NotFoundEmailException.java 작성

JwtTokenProvider 부분에 이메일이 없는 경우 이 에러처리를 한다.

public class NotFoundEmailException extends UserException {
    public NotFoundEmailException() {
        super(UserConstants.UserExceptionList.NOT_FOUND_EMAIL.getErrorCode(),
                UserConstants.UserExceptionList.NOT_FOUND_EMAIL.getHttpStatus(),
                UserConstants.UserExceptionList.NOT_FOUND_EMAIL.getMessage());
    }
}

참고자료

[Spring] Spring Security + JWT 토큰을 통한 로그인

JWT JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다. 현재 앱개발을 위해 REST API를 사용 중인데, 웹 상에서 Form을 통해 로그인하는 것이

gksdudrb922.tistory.com