Book Study/스프링 부트 핵심 가이드
13. 서비스의 인증과 권한 부여 (2)
정진킴
2023. 12. 12. 08:59
13.4 JWT
- JWT(Json Web Token) 은 URI로 이용할 수 있는 문자열로만 구성되어 있고, 디지털 서명이 적용돼 있어 신뢰할 수 있다.
13.4.1 JWT의 구조
- JWT는 점(.)으로 구분된 아래의 세 부분으로 구성된다.
- 헤더(Header)
- 내용(Content)
- 서명(Signature)
헤더
- JWT 헤더는 검증과 관련된 내용을 담고 있다.
- alg : 해싱 알고리즘을 지정한다.
- 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용한다.
- 토큰을 검증할 때 사용되는 서명 부분에 사용한다.
- typ : 토큰의 타입을 지정한다.
- Base64Url 형식으로 인코딩돼 사용한다.
- alg : 해싱 알고리즘을 지정한다.
{
"alg": H256, // HMAC SHA256 알고리즘
"typ": "JWT"
}
내용
- 토큰을 담는 정보를 포함한다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류한다.
- 등록된 클레임(Registered Claims)
- 공개 클레임(Public Claims)
- 키 값을 마음대로 정의할 수 있다. 다만 충돌이 발생하지 않을 이름으로 설정해야 한다.
- 비공개 클레임(Private Claims)
- 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미
- 등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다.
- iss: JWT의 발급자(Issure) 주체를 나타낸다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다.
- sub: JWT의 제목(Subject)
- aud: JWT의 수신인(Audience) 입니다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 합니다.
- 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부됩니다.
- exp: JWT의 만료시간(Expiration)입니다. 시간은 NumericDate 형식(ex) 1480849147370) 으로 지정해야 합니다.
- nbf: 'Not Before'를 의미
- iat: JWT가 발급된 시간(Issured at)
- jti: JWT의 식별자(JWT ID). 주로 중복 처리를 방지하기 위해 사용
{
"sub": "wikibooks payload",
"exp": "1602076408",
"userId": "wikibooks",
"username": "flature"
}
- Base64Url 형식으로 인코딩 되어 사용
서명
- 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성
- 예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성
13.4.2 JWT 디버거 사용하기
- JWT 공식 사이트에서 JWT 생성 가능 https://jwt.io/#debugger-io
JWT.IO
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
jwt.io
13.5 스프링 시큐리티와 JWT 적용
- 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성되어 있다.
- 해당 필터에서 인증이 실패하면 로그인 폼이 포함된 화면으로 전달하지만 해당 교제는 화면이 없다.
- 대안으로 JWT를 사용하는 인증 필터를 구현하고 usernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경한다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); //계정이 가지고 있는 권한 목록을 리턴
String getPassword(); // 계정의 비밀번호를 리턴
String getUsername(); // 계정의 이름을 리턴. 일반적으로 ID를 리턴
boolean isAccountNonExpired(); // 계정 만료여부 리턴, true - 만료되지 않음
boolean isAccountNonLocked(); // 계정 잠금여부 리턴, true - 잠기지 않음
boolean isCredentialsNonExpired(); // 비밀번호 만료여부 리턴, true - 만료되지 않음
boolean isEnabled(); // 계정 활성화 여부 리턴, true - 활성화 상태를 의미
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User getById(String uid);
}
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailService {
private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
return userRepository.getByUid(username);
}
}
- UserDetailService를 구현하도록 설정되어 있고, loadUserByUsername() 메서드를 구현하도록 정의되어 있다.
- UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미한다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
- username은 각 사용자를 구분할 수 있는 ID를 의미
- username을 가지고 UserDetails 객체를 리턴하게끔 정의되어 있다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsContructor
@Entity
@Builder
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
...
}
- UserDetail의 구현체로 User 엔티티를 생성했기 때문에 User 객체를 리턴하도록 구현
13.5.2 JwtTokenProvider 구현
- JWT 토큰을 생성하는 JwtTokenProvider를 생성
@Component // 애플리케이션 가동되면 Bean으로 자동 주입
@RequredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey"; // application.properties 에 값이 없는 경우 설정
private final long tokenValidMillisecond = 1000L * 60 * 60;
// Bean 으로 자동 주입 시 자동으로 실행
@PostConstruct // 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
secretKey = Base64.getEncoder()
.encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
public String createToken(String userUid, List<String> roles) {
LOGGER.info("[createToken] 토큰 생성 시작");
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles); // 사용자 권한 확인을 위해 별도로 추가
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlogrithm.HS256, secretKey)
.compat();
LOGGER.info("[createToken] 토큰 생성 완료");
return token;
}
/**
* 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는
* 역할을 한다.
* Authentication 을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용한다.
*/
public Authentication getAuthentication(String token) {
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails = userDetailsService
.loadUserByUsername(this.getUserName(token));
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName: {}",
userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(userDetails,
"",
userDetails.getAuthorities());
}
public String getUsername(String token) {
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
public boolean validateToken(String token) {
LOGGER.info("[validToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimseJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
- UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 AbstractAuthenticationToken은 Authentication 인터페이스의 구현체입니다.
- 이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요하다.
13.5.3 JwtAuthenticationFilter 구현
- JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스
@Configuration
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void doFilterInternal(ServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
LOGGER.info("[doFilterInternal] token 값 추출 완료, token : {}", token);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유혀성 체크 완료");
}
// doFilter를 기준으로 서블릿이 실행되기 전에 실행
filterChain.doFilter(servletRequest, servletResponse);
// doFilter를 기준으로 서블릿이 실행된 후 실행
}
}
- GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스이다.
- 다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해두고 동일한 클라이언트의 요청을 받으면 재활용하는 구조여서 GenericFilterBean을 상속받으면 RequestDispathcer에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행되는 현상이 발생할 수 있다.
- 이 같은 문제를 해결하기 위해 등장한 것이 OncePerRequestFilter이며, 이 클래스 역시 GenericFilterBean을 상속받고 있다.
- 다만 이 클래스를 상속받아 구현한 필터는 매 요청마다 한 번만 실행되게끔 구현된다.
13.5.4 SecurityConfiguration 구현
- 스프링 시큐리티와 관련된 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void configure(HttpSecurity httpSecurity) thorws Exception {
// 모든 설정은 HttpSecurity 에 설정된다.
httpSecurity.httpBasic().disable() // UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화.
.csrf().disable() // CSRF 보안 비활성화 (브라우저 사용이 아닌 경우)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않는 설정
.and()
.authorizeRequests() // 요청에 대한 권한 체크
.antMatchers("/sing-api/sign-in", "sign-api/sign-up", "/sign-api/exception") // 해당 경로 허용
.permitAll()
.antMachers(HttpMethod.GET, "/product/**") // /product로 시작하는 경로의 GET 요청 모두 허용
.permitAll()
.antMachers("**exception**") // 'exception' 단어가 들어간 경로 모두 허용
.permitAll()
.andRequest().hasRole("ADMIN") // ADMIN 인증 권한 가진 사용자 허용
.and()
.exceptionHandling().accessDeniedHandler(new CustomAuthenticationEntryPoint()) // 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증 과정에서 예외가 발생할 경우 예외를 전달
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider).
UsernamePasswordAuthenticationFilter.class); // UsernamePasswordAuthenticationFilter 앞에 정의하여 해당 클래스는 자동 통과됨
}
/**
* WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다.
* 인증과 인가 모두 적용되기 전에 동작하는 설정
* 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용
* ignore() 를 사용하여 예외 추가
*/
@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("v2/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "./webjars/**", "/swagger/**", "/sign-api/exception");
}
}
- HttpSecurity 파라미터를 받은 configure() 메서드 부터 보자
- HttpSecurity를 통해 진행되고 대표적인 기능은 아래와 같다.
- 리소스 접근 권한 설정
- 인증 실패 시 발생하는 예외 처리
- 인증 로직 커스터마이징
- csrf, cors 등의 스프링 시큐리티 설정
- HttpSecurity를 통해 진행되고 대표적인 기능은 아래와 같다.
13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현
- AccessDeniedHandler 인터페이스 작성
- handle() 메서드를 오버라이딩해서 구현
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServleRequest request, HttpSevletResponse response,
AccessDeniedException exception) throws IOException {
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
- AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외
- 해당 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용되며, SecurityConfiguration에도 exceptionHandling() 메서드를 통해 추가
- 파라미터로 HttpServletRequest, HttpSevletResponse, AccessDeniedException을 파라미터로 가져온다.
2. AuthenticationEntryPoint 인터페이스 작성
- commence() 메서드를 오버라이딩해서 코드를 구현합니다.
- HttpServletRequest, HttpServletResponse, AuthenticationException을 매개변수로 받고, 예외처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationExeception ex) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
LOGGER.info("[commence] 인증 실패로 response.sendError 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().wrtie(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}