상세 컨텐츠

본문 제목

Spring Security + JWT 인증 (1/2) - JWT 구현

Spring/Spring Security

by Chan.94 2024. 8. 20. 16:35

본문

반응형

SpringBoot환경에서 Spring Security기반에 JWT 인증 방식을 구현하도록 하겠다.

Spring Security와 JWT에 대한 개념은 아래 포스팅을 참고하기 바란다.

 

Spring Security 개념 및 Architecture

JWT(Json Web Token) 정리

 

Spring Security 설정은 아래 포스팅을 참고하기 바란다.

 

Spring Security + JWT 인증 (2/2) - Spring Security 설정


JWT 구현 요약

  • Access Token + Refresh Token 발행
    (Access Token은 Refresh Token보다 만료기간 짧게 설정)
  • Refresh Token은 DB에 저장
    (Refresh Token은 UUID로 발행)
  • Refresh Token은 일회성으로 사용된다.


dependency 추가

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

 

application.yml

jwt:
  header: Authorization
  type: Bearer 
  secret: EStHVT61mNgGaJUWO/+90UAyLbhLfvbVEvkE1r1hhCQ=
  #1800000: 30분
  access-expiration-time: 1800000
  refresh-expiration-time: 3600000

 

JWT Secret key는 아래 홈페이지에서 생성할 수 있다. JWT에서 사용할 Algorithm에 맞게 secret key를 간편하게 발급할 수 있다. (HS256 사용)

https://jwt-keys.21no.de/

 

JWT Keys Generator API UI

 

jwt-keys.21no.de


AuthController.java

import java.util.HashMap;

import javax.servlet.http.HttpServletRequest;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
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;

import com.devlog.common.jwt.JwtCode;
import com.devlog.common.jwt.JwtProvider;
import com.devlog.security.PrincipalDetails;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

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

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtProvider jwtProvider;

    @PostMapping("/login")
    public ResponseEntity<HashMap<String, String>> login(@RequestBody HashMap<String, String> requestBody) {
        
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(requestBody.get("email"), requestBody.get("password"));
        
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        
        String sAccessToken = jwtProvider.generateAccessToken(authentication);
        String sRefreshToken = jwtProvider.generateRefreshToken(principalDetails.getUserId());
        
        HashMap<String, String> responseBody = new HashMap<String, String>();
        responseBody.put("userId", String.valueOf(principalDetails.getUserId()));
        responseBody.put("userName", principalDetails.getUsername());
        responseBody.put("refreshToken", sRefreshToken);
        
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(jwtProvider.getJwtHeader(), jwtProvider.addTokenType(sAccessToken));
        
        return new ResponseEntity<>(responseBody, httpHeaders, HttpStatus.OK);
    }
    
    @PostMapping("/refresh")
    public ResponseEntity<HashMap<String, String>> refresh(HttpServletRequest httpServletRequest, @RequestBody HashMap<String, String> requestBody) {

        HashMap<String, String> responseBody = new HashMap<String, String>();
        HttpHeaders httpHeaders = new HttpHeaders();
        
        String inputRefreshToken = requestBody.get("refreshToken");
        
        String sAccessToken = jwtProvider.resolveToken(httpServletRequest);

        JwtCode jwtCode = jwtProvider.validateToken(sAccessToken);
        
        switch (jwtCode) {
        case ACCESS:
            log.info("JWT Token Not Expired");
            responseBody.put("error", "JWT Token Not Expired");
            break;
        case EXPIRED:
            Claims claims = jwtProvider.parseClaims(sAccessToken);
            if (claims.get("userId") == null) {
                log.info("Invalid JWT Token");
                return null;
            }
            Long userId = Long.parseLong(claims.get("userId").toString());
            
            boolean bRefreshTokenValid = jwtProvider.validateRefreshToken(userId, inputRefreshToken);
            
            if(bRefreshTokenValid) {
                Authentication authentication = jwtProvider.getAuthentication(sAccessToken);
                String sNewAccessToken = jwtProvider.generateAccessToken(authentication);
                String sNewRefreshToken = jwtProvider.generateRefreshToken(userId);
                
                SecurityContextHolder.getContext().setAuthentication(authentication);
                
                httpHeaders.add(jwtProvider.getJwtHeader(), jwtProvider.addTokenType(sNewAccessToken));

                responseBody.put("msg", "Expried Token Re Issue");
                responseBody.put("refreshToken", sNewRefreshToken);
            }
            else {
                log.info("Invalid JWT Token");
                responseBody.put("error", "Invalid JWT Token");
            }
            break;

        default:
            log.info("Invalid JWT Token");
            responseBody.put("error", "Invalid JWT Token");
            break;
        }
        
        return new ResponseEntity<>(responseBody, httpHeaders, HttpStatus.OK);
    }
}

 

  • /auth/** URL은 Spring Security의 권한 체크와 JWT 인증을 제외한다.

1) /auth/login

UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(requestBody.get("email"), requestBody.get("password"));

 

  • 클라이언트로부터 ID / PW로 UsernamePasswordAuthenticationToken의 인증용 객체(인증 완료 전)를 생성한다.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

 

  • 인증을 절차를 진행한다.
  • UserDetailsService를 implements 한 CustomUserDetailsService의 loadUserByUsername가 실행되어 입력받은 아이디에 대한 사용자 정보를 DB에서 조회한다.
    조회한 사용자의 Encoded 된 password와 입력받은 password를 PasswordEncoder로 Encoding 한 값과 매칭하는지 확인한다.
    같으면 인증이 완료되고 인증이 완료된 Authentication 객체를 리턴한다.
    (UserDetailsService 구현체는 DaoAuthenticationProvider에 의해 실행된다.)
  • 인증이 성공하면 인증 객체를 생성하여 Security Context에 저장.
    인증이 실패한 경우에는 AuthenticationException.
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();

 

  • Authentication에서 접근 주체(Principal)에 대한 정보를 가지고 올 수 있다.
  • PrincipalDetails은 UserDetails을 implements 한 객체.
    (org.springframework.security.core.userdetails.UserDetails)
String sAccessToken = jwtProvider.generateAccessToken(authentication);
String sRefreshToken = jwtProvider.generateRefreshToken(principalDetails.getUserId());

 

  • AccessToken 생성
  • RefreshToken 생성 및 DB 저장
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(jwtProvider.getJwtHeader(), jwtProvider.addTokenType(sAccessToken));

return new ResponseEntity<>(responseBody, httpHeaders, HttpStatus.OK);

 

  • Header에 AccessToken 전달

2) /auth/refresh

Access Token이 만료되고 로그인 시 발행한 Refresh Token을 사용하여 Access Token 재발급한다.

 

String sAccessToken = jwtProvider.resolveToken(httpServletRequest);
JwtCode jwtCode = jwtProvider.validateToken(sAccessToken);

 

  • httpServletRequest에서 Access Token 추출
  • Access Token 검증
case EXPIRED:
    Claims claims = jwtProvider.parseClaims(sAccessToken);
    if (claims.get("userId") == null) {
        log.info("Invalid JWT Token");
        return null;
    }
    Long userId = Long.parseLong(claims.get("userId").toString());

 

  • Access Token에서 Claims 추출
  • Access Token발급 시 Claims에 userId를 포함하였음.
    (이 부분은 각자 상황에 맞게 구현하면 될 것)
boolean bRefreshTokenValid = jwtProvider.validateRefreshToken(userId, inputRefreshToken);

if(bRefreshTokenValid) {
    Authentication authentication = jwtProvider.getAuthentication(sAccessToken);
    String sNewAccessToken = jwtProvider.generateAccessToken(authentication);
    String sNewRefreshToken = jwtProvider.generateRefreshToken(userId);
    
    SecurityContextHolder.getContext().setAuthentication(authentication);
    
    httpHeaders.add(jwtProvider.getJwtHeader(), jwtProvider.addTokenType(sNewAccessToken));

    responseBody.put("msg", "Expried Token Re Issue");
    responseBody.put("refreshToken", sNewRefreshToken);
}

 

  • Access Token에서 추출한 userId와, 전달받은 RefreshToken를 이용하여 검증을 진행한다.
  • 검증이 완료되면 Access Token과 Refresh Token을 재발행한다.
  • RTR(Refresh Token Rotation) 기법 적용
    Refresh Token을 사용하는 이유에 대해 잠시 생각해 보자.
    Access Token의 만료기간을 길게 발행하여 탈취당하게 되면 대처가 어렵다. 이러한 이유로 Access Token은 만료기간을 상대적으로 짧게 Refresh Token은 Access Token보다 길게 발행하기로 하였다.
    Access Token의 탈취된 것을 알게 되면 Refresh Token의 만료기간을 수정하여 만료시키면 대처가 가능하기 때문이다.
    Access Token과 Refresh Token이 함께 탈취가 된다면?
    Refresh Token은 유효기간도 길어서, 한번 탈취되면 만료 시까지 해커가 계속 사용할 수 있다.

    이러한 이유로 RTR(Refresh Token Rotation) 기법 적용되었다.
    Access Token이 재발행 될때 Refresh Token도 함께 갱신시킨다.

JwtProvider.java

import java.security.Key;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

import javax.annotation.PostConstruct;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.devlog.common.entity.RefreshToken;
import com.devlog.common.entity.User;
import com.devlog.security.PrincipalDetails;
import com.devlog.security.RefreshTokenRepository;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class JwtProvider {

    private static final String AUTHORITIES_KEY = "auth";
    
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.access-expiration-time}")
    private long accessExpirationTime;
    @Value("${jwt.refresh-expiration-time}")
    private long refreshExpirationTime;
    @Value("${jwt.header}")
    private String jwtHeader;
    @Value("${jwt.type}")
    private String jwtType;
    
    private Key key;
    
    @Autowired
    private RefreshTokenRepository refreshTokenRepository;
    
    @PostConstruct
    public void init() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
    
    /**
     * Access Token 생성
     */
    public String generateAccessToken(Authentication authentication) {
        // 인증된 사용자의 권한 목록 조회
        String authorities = authentication.getAuthorities().stream()
                                                            .map(GrantedAuthority::getAuthority)
                                                            .collect(Collectors.joining(","));

        // 토큰의 expire 시간을 설정
        long now = (new Date()).getTime();
        Date expiration = new Date(now + accessExpirationTime);
        
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();

        return Jwts.builder()
                    .setSubject(authentication.getName())
                    .claim("userId", principalDetails.getUserId())
                    .claim(AUTHORITIES_KEY, authorities)     
                    .setExpiration(expiration)               
                    .signWith(key, SignatureAlgorithm.HS256)
                    .compact();
    }
    
    /**
     * Refresh Token 생성
     */
    @Transactional
    public String generateRefreshToken(Long userId) {
        long issue = (new Date()).getTime();
        long expired = (new Date(issue + refreshExpirationTime)).getTime();
        String uuid = UUID.randomUUID().toString();
        
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUserId(userId);
        refreshToken.setToken(uuid);
        refreshToken.setIssue(String.valueOf(issue));
        refreshToken.setExpired(String.valueOf(expired));
        
        refreshTokenRepository.save(refreshToken);
        
        return uuid;
    }
    
    /**
     * 토큰에서 인증 정보 조회 후 Authentication 객체 리턴
     * 
     * 토큰 -> 클레임 추출 -> 유저 객체 제작 -> Authentication 객체 리턴
     */
    public Authentication getAuthentication(String token) {
        Claims claims = this.parseClaims(token);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }
        
        List<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                                                            .map(SimpleGrantedAuthority::new)
                                                            .collect(Collectors.toList());

        User user = new User();
        user.setUsername(claims.getSubject());
        user.setUserId(Long.parseLong(claims.get("userId").toString()));
        user.setAuthority(claims.get(AUTHORITIES_KEY).toString());
        PrincipalDetails principal = new PrincipalDetails(user);
        
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }
    
    /**
     * JWT Claims 복호화
     */
    public Claims parseClaims(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
        }catch (ExpiredJwtException  e) {
            return e.getClaims();
        }
    }
    
    /**
     * 토큰 검증
     */
    public JwtCode validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return JwtCode.ACCESS;
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token");
            return JwtCode.EXPIRED;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token");
            return JwtCode.DENIED;
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token");
            return JwtCode.DENIED;
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty");
            return JwtCode.DENIED;
        } catch (Exception e) {
            log.info("Exception");
            return JwtCode.DENIED;
        }
    }
    
    /**
     * Refresh Token 검증
     */
    public boolean validateRefreshToken(Long userId, String validRefreshToken) {
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId);
        
        if(!refreshToken.getToken().equals(validRefreshToken)) {
            return false;
        }
        
        long now = (new Date()).getTime();
        long expired = Long.parseLong(refreshToken.getExpired());
        
        if(now > expired) {
            return false;
        }
        return true;
    }
    
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(jwtHeader);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtType)) {
            return bearerToken.substring(7);
        }
        return null;
    }
    
    /**
     * prefix Token Type Add
     */
    public String addTokenType(String token) {
    	return String.format("%s %s", jwtType, token);
    }
    
    public String getJwtHeader() {
        return jwtHeader;
    }
    public String getJwtType() {
        return jwtType;
    }
}

 

JWT 토큰 생성, 추출 그리고 검증에 관한 내용이 정의되어 있다.

 

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

 

  • yml에 설정한 SecretKey를 signWith 메서드 인수로 사용할 Key 인스턴스로 변환해야 한다.

 

/**
 * Access Token 생성
 */
public String generateAccessToken(Authentication authentication) {
    // 인증된 사용자의 권한 목록 조회
    String authorities = authentication.getAuthorities().stream()
                                                        .map(GrantedAuthority::getAuthority)
                                                        .collect(Collectors.joining(","));

    // 토큰의 expire 시간을 설정
    long now = (new Date()).getTime();
    Date expiration = new Date(now + accessExpirationTime);
    
    PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();

    return Jwts.builder()
                .setSubject(authentication.getName())
                .claim("userId", principalDetails.getUserId())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(expiration)                 
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
}

 

  • Access Token을 생성한다.
  • yml에 설정된 Access Token 만료시간(밀리초)이 반영.
    Refresh Token보다 짧게 설정됨
  • JWT Payload에 정보를 담는다.
    - subject(제목) : 접근주체의 이름
    - claim : userId정보 포함 (Refresh Token 검증 시 사용)
    - expiration(토큰 만료 시간) : 현재시간(밀리초) + 만료시간(밀리초)
                                                   설정하지 않으면 토큰이 만료되지 않는다.
  • Sign(서명)
    header의 alg에도 알고리즘이 지정됨
@Transactional
public String generateRefreshToken(Long userId) {
    long issue = (new Date()).getTime();
    long expired = (new Date(issue + refreshExpirationTime)).getTime();
    String uuid = UUID.randomUUID().toString();
    
    RefreshToken refreshToken = new RefreshToken();
    refreshToken.setUserId(userId);
    refreshToken.setToken(uuid);
    refreshToken.setIssue(String.valueOf(issue));
    refreshToken.setExpired(String.valueOf(expired));
    
    refreshTokenRepository.save(refreshToken);
    
    return uuid;
}

 

  • yml에 설정된 Refresh Token 만료시간(밀리초)이 반영.
    Access Token보다 길게 설정됨
  • Refresh Token UUID로 생성 후 DB저장
/**
 * 토큰에서 인증 정보 조회 후 Authentication 객체 리턴
 * 
 * 토큰 -> 클레임 추출 -> 유저 객체 제작 -> Authentication 객체 리턴
 */
public Authentication getAuthentication(String token) {
    Claims claims = this.parseClaims(token);

    if (claims.get(AUTHORITIES_KEY) == null) {
        throw new RuntimeException("권한 정보가 없는 토큰입니다.");
    }
    
    List<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                                                        .map(SimpleGrantedAuthority::new)
                                                        .collect(Collectors.toList());

    User user = new User();
    user.setUsername(claims.getSubject());
    user.setUserId(Long.parseLong(claims.get("userId").toString()));
    user.setAuthority(claims.get(AUTHORITIES_KEY).toString());
    PrincipalDetails principal = new PrincipalDetails(user);
    
    return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}

 

  • Access Token에서 Claims 추출
  • Claims 정보로 PrincipalDetails객체 생성(UserDetails 구현체) 생성
    DB를 거치지 않기 때문에 토근에 있는 정보만 알 수 있다.
    (org.springframework.security.core.userdetails.UserDetails)
  • Authentication(인증) 객체 생성
/**
 * Refresh Token 검증
 */
public boolean validateRefreshToken(Long userId, String validRefreshToken) {
    RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId);
    
    if(!refreshToken.getToken().equals(validRefreshToken)) {
        return false;
    }
    
    long now = (new Date()).getTime();
    long expired = Long.parseLong(refreshToken.getExpired());
    
    if(now > expired) {
        return false;
    }
    return true;
}

 

  • 전달받은 userId로 DB에 저장되어 있는 Refresh Token을 조회하고 일치여부를 검증한다.
  • Refresh Token의 유효기간을 검증한다.
public String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(jwtHeader);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtType)) {
        return bearerToken.substring(7);
    }
    return null;
}

 

  • httpServletRequest의 header에서 Access Token을 추출한다.
  • 로그인 시 발급된 Access Token에 prefix로 "bearer "를 붙였으며 Header에 담아서 Response 함.

 


User.java

@Setter
@Getter
@Entity
@Table(name = "DEV_USER")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long userId;

    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String authority;
}

 

PrincipalDetails.java

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.devlog.common.entity.User;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class PrincipalDetails implements UserDetails{
	private User user;
	
	public PrincipalDetails (User user) {
		this.user = user;
	}

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

	@Override
	public String getUsername() {
		return user.getUsername();
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collectors = new ArrayList<>();
		for (String role : user.getAuthority().split(",")) {
			collectors.add(new SimpleGrantedAuthority(role));
		}
		return collectors;
	}

	
	/**
	 * 계정 만료 여부 반환
	 * 
	 * true -> 만료되지 않았음
	 */
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	/**
	 * 계정 잠금 여부 반환
	 * 
	 * true -> 잠금되지 않았음
	 */
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	/**
	 * 패스워드의 만료 여부 반환
	 * 
	 * true -> 만료되지 않았음
	 */
	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	/**
	 * 계정 사용 가능 여부 반환
	 * 
	 * true -> 사용 가능
	 */
	@Override
	public boolean isEnabled() {
		return true;
	}

	public String getAuthority() {
		return user.getAuthority();
	}

	public String getEmail() {
		return user.getEmail();
	}

	public long getUserId() {
		return user.getUserId();
	}

}

 

  • PrincipalDetails 객체는 UserDetails의 구현체.
    (org.springframework.security.core.userdetails.UserDetails)
  • User 객체를 상속받아도 되고 멤버로 구현해도 된다.
  • GrantedAuthority는 현재 사용자(Principal)가 가지고 있는 권한을 의미한다.
    Override 한 getAuthorities() 메서드는 Spring Security에서 Authorization(인가)에 대한 표현식을 어떻게 할 것인지에 따라 결정되어야 한다.
    - hasRole(String) 사용 시 prefix로 "ROLE_"을 추가해야 함
    - hasAuthority(String) 사용 시 그대로 사용

RefreshToken.java

@Setter
@Getter
@Entity
@Table(name = "DEV_RE_TOKEN")
public class RefreshToken {

    @Id
    private Long userId;

    @Column(unique = true, nullable = false)
    private String token;
    
    @Column(nullable = false)
    private String issue;
    
    @Column(nullable = false)
    private String expired;
    
}

 

JwtCode.java

public enum JwtCode {
	ACCESS("ACCESS"),
	EXPIRED("EXPIRED"),
	DENIED("DENIED"),
	;

	private final String code;
	
	JwtCode(String code) {
		this.code = code;
	}

	public String getCode() {
		return code;
	}
}

 


JwtAuthenticationEntryPoint.java

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

        @Override
        public void commence(HttpServletRequest request
                                                , HttpServletResponse response
                                                , AuthenticationException authException) throws IOException, ServletException {
                log.error("URI : {} , MESSAGE : {} , Error : {}" ,request.getRequestURI()
                                                                 , authException.getMessage()
                                                                 , HttpServletResponse.SC_UNAUTHORIZED);
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        }

}

 

  • Spring Security 설정 시 ExceptionHandling에 추가
  • 인증(Authentication)이 실패했을 때 실행.
  • SC_UNAUTHORIZED(401) Error

JwtAccessDeniedHandler.java

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler{

    @Override
    public void handle(HttpServletRequest request
                        , HttpServletResponse response
                        , AccessDeniedException accessDeniedException) throws IOException, ServletException {
        
        log.error("JWT : {}, URI : {}, MESSAGE : {}, Error : {}" , request.getHeader("Authorization")
                                                                , request.getRequestURI()
                                                                , accessDeniedException.getMessage()
                                                                , HttpServletResponse.SC_FORBIDDEN);
        
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }

}

 

  • Spring Security 설정 시 ExceptionHandling에 추가
  • 인가(Authorization)가 실패했을 때 실행
  • SC_FORBIDDEN(403) Error

JwtAuthenticationFilter.java

import java.io.IOException;
import java.util.Arrays;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import com.devlog.common.jwt.JwtCode;
import com.devlog.common.jwt.JwtProvider;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter{

    private final JwtProvider jwtProvider;
    
    private String[] PERMIT_ALL_RESOURCES;
    
    public JwtAuthenticationFilter (JwtProvider jwtProvider, String ... permitAllResources) {
        this.jwtProvider = jwtProvider;
        this.PERMIT_ALL_RESOURCES = permitAllResources;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest
                                    , HttpServletResponse httpServletResponse
                                    , FilterChain filterChain) throws ServletException, IOException {
        
        // Request에서 Access Token 추출
        String sAccessToken = jwtProvider.resolveToken(httpServletRequest);
        if (sAccessToken == null || !StringUtils.hasText(sAccessToken)){
            log.info("Invalid JWT Token : " + httpServletRequest.getServletPath());
            httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
        }
        
        // Access Token 검증
        JwtCode jwtCode = jwtProvider.validateToken(sAccessToken);
        
        switch (jwtCode) {
        case ACCESS:
            // Access Token에서 인증(Authentication)객체 생성
            Authentication authentication = jwtProvider.getAuthentication(sAccessToken);
            
            // security context에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(authentication); 
            break;

        case EXPIRED:
            log.info("Access token expired");
            httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);    //401
            break;

        case DENIED:
            log.info("Invalid JWT Token");
            httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
            break;
            
        default:
            log.info("Invalid JWT Token");
            httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
            break;
        }
        
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
    
    /**
     * 필터링에서 제외시키고 싶은 request
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return Arrays.stream(PERMIT_ALL_RESOURCES)
                .anyMatch(permit -> new AntPathMatcher().match(permit, request.getRequestURI()));
    }
}

 

  • JWT 인증 Filter 구현부
  • OncePerRequestFilter를 상속받아 구현
    여러 레퍼런스를 참고하다 보면 GenericFilterBean을 사용하거나 OncePerRequestFilter를 사용하는 것을 확인했을 것이다. 두 개의 차이에 대해 간단히 정리하고 넘어가겠다.

    GenericFilterBean
    요청 시마다 Filter가 실행된다. Spring Security에 적용하게 되면 의도치 않게 두 번씩 적용되는 경우가 발생할 수 있다.
    그것을 해결하기 위해 OncePerRequestFilter를 사용한다.

    OncePerRequestFilter
    모든 서블릿에서 일관된 요청을 처리하기 위해 만들어진 필터이다.
    이 추상 클래스를 구현한 필터는 사용자의 한 번에 요청 당 딱 한 번만 실행되는 필터를 만들 수 있다.

    첫 번째 API에서 요청을 처리하고 두 번째 API로 redirect 시킨다고 가정하자. 그렇게 처리할 경우, 클라이언트는 한 번의 요청을 한 것뿐이지만 흐름상 요청을 두 번 한 것과 같이 처리될 것이고 Filter가 두 번씩 적용되는 문제가 발생한다. OncePerRequestFilter를 상속받아 구현하면 해결할 수 있다.
private final JwtProvider jwtProvider;
private String[] PERMIT_ALL_RESOURCES;

public JwtAuthenticationFilter (JwtProvider jwtProvider, String ... permitAllResources) {
    this.jwtProvider = jwtProvider;
    this.PERMIT_ALL_RESOURCES = permitAllResources;
}

 

  • JwtProvider와 PERMIT_ALL_RESOURCES[] 는 Spring Security에서 생성하여 주입할 것이다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
    return Arrays.stream(PERMIT_ALL_RESOURCES)
            .anyMatch(permit -> new AntPathMatcher().match(permit, request.getRequestURI()));
}

 

  • shouldNotFilter를 override 하여 필터링에서 제외시키고 싶은 URL을 정의한다.
  • PERMIT_ALL_RESOURCES[]에는 JWT인증을 제외할 URL이 정의되어 있다.
    ex) 로그인, 회원가입
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest
                                , HttpServletResponse httpServletResponse
                                , FilterChain filterChain) throws ServletException, IOException {
    
    // Request에서 Access Token 추출
    String sAccessToken = jwtProvider.resolveToken(httpServletRequest);
    if (sAccessToken == null || !StringUtils.hasText(sAccessToken)){
        log.info("Invalid JWT Token : " + httpServletRequest.getServletPath());
        httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
    
    // Access Token 검증
    JwtCode jwtCode = jwtProvider.validateToken(sAccessToken);
    
    switch (jwtCode) {
    case ACCESS:
        // Access Token에서 인증(Authentication)객체 생성
        Authentication authentication = jwtProvider.getAuthentication(sAccessToken);
        
        // security context에 인증 정보 저장
        SecurityContextHolder.getContext().setAuthentication(authentication); 
        break;

    case EXPIRED:
        log.info("Access token expired");
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);    //401
        break;

    case DENIED:
        log.info("Invalid JWT Token");
        httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
        break;
        
    default:
        log.info("Invalid JWT Token");
        httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
        break;
    }
    
    filterChain.doFilter(httpServletRequest, httpServletResponse);
}

 

  • Request에서 Access Token 추출
  • Access Token 검증
    ACCESS
    - Access Token에서 인증(Authentication) 객체 생성
    - security context에 인증 정보 저장

    EXPIRED
    - 401 Error를 Response 한다.
    - 클라이언트는 로그인 시 발급한 Refresh Token과 Access Token을 서버에 전달하여 Access Token을 갱신한다.

로그인 테스트 결과 미리 보기

Spring Security를 구현하지 않아 위에 설명된 인스턴스들을 구현해도 실행되지는 않을 것이다. 

이쯤에서 부분적으로 결과를 보고 가는 게 좋을 것 같아 테스트 결과를 일부 작성한다.

 

  • Header 정보에서 Access Token 확인
  • Refresh Token 확인

 

Spring Security 설정 은 다음 포스팅을 확인하기 바란다.

 

Spring Security + JWT 인증 (2/2) - Spring Security 설정

반응형

관련글 더보기

댓글 영역

>