JWT란 무엇인가 ?
RFC 7519 웹 표준으로 지정되어 있고, JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Json Web Token이다.
헤비 하지 않고, 아주 간편하고 쉽게 적용할 수 있어서 사이드 프로젝트를 진행할 때 매우 유용한 방식이다. 몇 가지 고려사항만 잘 대응하면, 대규모 프로젝트에도 충분히 이용 가능한 방식이다.
JWT 구성요소
Header : Signature를 해싱하기 위한 알고리즘 정보들이 담겨있음.
Payload : 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용들을 담고 있다.
Signature : 토큰이 유효성 검증을 위한 문자열, 이 토큰이 유효한 토큰인지 확인 가능.
JWT 장점
- 중앙의 인증서버, 데이터 스토어에 대한 의존성 없음, 시스템 수평 확장 유리
- 토큰이 클라이언트에 저장, 서버에서 클라이언트의 토큰을 조작할 수 없음
Client가 Server 측으로부터 토큰을 발급받는 과정
- 클라이언트가 서버에 ID/PW를 입력해 로그인 시도
- 서버 측에서 ID/PW가 해당 클라이언트의 ID/PW와 일치한 지 확인 후 Access Token + Refrest Token 발급 후 클라이언트 측으로 전달
- 클라이언트 측에서 API 요청 시 Access Token 헤더에 담아서 요청
- 서버 측에서 API 응답 혹은 Access Token 만료 응답을 전달
- 발급받은 토큰이 만료되면, 서버 측으로 재발급 요청(Access Token + Refresh Token)
- 서버 측에서 토큰 검증 후 새로운 Access Token + Refresh Token 발급
추가해줘야 할 디펜던시(build.gradle에 추가 or 프로젝트 생성 시 Spring Security 추가)
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
TokenProvider : 토큰의 유효성 검증 및 생성을 위함
@Component
@PropertySource("classpath:jwt.properties")
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// bean이 생성되고 주입을 받은 후에 secret값을 Base64 Decode해서 key 변수에 할당
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
//만료 시간 설정
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
// Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드 추가
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// Token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성
// 토큰으로 클레임을 만들고 이를 이용해 유저 객체를 만들어서 최종적으로 Authentication 객체를 리턴
// 권한정보를 이용 유저객체를 만들어주고, 유저정보, 토큰, 권한정보를 이용해 Authentication 객체를 리턴
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
// 토큰의 유효성 검증, 토큰을 파싱해보고 발생하는 예외 처리, 문제 있으면 false, 정상이면 true
}
우선적으로, Token의 시크릿 키 값과 만료시간을 application.properties에 따로 설정해줘야 한다.
TokenProvider 클래스에서는 authentication 객체의 권한정보를 이용해 토큰을 생성하는 메소드, 생성된 토큰, 권한정보, 객체를 이용하여 Authentication 객체를 리턴하는 메소드, 토큰의 유효성을 검증하는 메소드가 담겨있다.
JwtFilter
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
GenericFilterBean을 extends해서 doFilter Override, 실제 필터링 로직은 doFilter 내부에 작성
doFilter(): JWT Token의 인증정보를 SecurityContext에 저장하는 역할 수행
resolveToken(): Request Header에서 토큰 정보를 꺼내오기 위한 메소드
resolveToken을 통해 토큰을 받아와서 유효성 검증을 하고, 정상 토큰이면 SecurityContext에 저장
JwtSecurityConfig
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용하기 위한 클래스
SecurityConfigurerAdpater를 extends하고 TokenProvider를 주입받아서 JwtFilter를 통해 Security로직에 필터를 등록
JwtAuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
사용자 정보가 잘못되거나, 토큰이 유효하지 않은 경우에 대비하기 위한 클래스이다.(401 Unauthorized)
JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
필요한 권한이 존재하지 않은 경우, 403 Forbidden 에러를 리턴하기 위한 클래스이다.
예시를 들어, 유저, 관리자, 매니저 역할이 있을 때, 유저는 유저 권한만 가져야 하고, 매니저는 유저, 매니저, 관리자는 모든 권한을 가져야 한다. 유저가 관리자 권한에 접근할 수 없기에, 이를 대비하기 위한 클래스로 볼 수 있다.
CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
프론트엔드, 백엔드를 구분지어서 개발하거나, 서로 다른 Server 환경에서 자원을 공유할 때,
Cors설정이 안 되어있으면 오류가 발생한다. 이를 방지하기 위해 Cors 설정을 해주어야 한다.
SecurityConfig
Spring Security 설정 파일, SecurityConfig는 TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 주입
@EnableMethodSecurity
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// enable h2-console
.and()
.headers()
.frameOptions()
.sameOrigin()
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
//로그인, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에, 모두 permitAll 설정
.and()
.apply(new JwtSecurityConfig(tokenProvider));
// JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용
return httpSecurity.build();
}
}
addFilterBefore() : 필터를 등록한다. 스프링 시큐리티 필터링에 등록해주어야 하기 때문에, 여기에 등록해주어야 한다. 매개변수는 2가지가 들어간다. 왼쪽은 커스텀한 필터링(corsFilter)이 들어간다. 오른쪽에 등록한 필터 전에 커스텀 필터링(usernamePasswordAuthenticataionFilter)이 수행된다.
requestMatcher() : 해당 URL 요청이 들어오면, 설정을 해준다.
authenticated() : 인증 필요
사용자 데이터를 세션에 저장하지 않고, Json 객체에 담기 때문에 세션을 STATELESS로 설정한다 (비활성화)
Jwt 패키지 이외에도, 외부와의 통신을 사용하기 위한 DTO패키지를 따로 만들어주었고, Repository도 따로 만들어주었습니다.
CustomUserDetailsService
@Component("userDetailsService")
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
return userRepository.findOneWithAuthoritiesByUsername(username)
.map(user -> createUser(username, user))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, User user) {
if (!user.isActivated()) {
throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
}
List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(user.getUsername(),
user.getPassword(),
grantedAuthorities);
}
}
UserDetailsService를 implements 하고 UserRepository를 주입받는다.
loadUserByUsername 메소드를 오버라이드해서 로그인시 DB에서 유저정보와 권한정보를 가져오게 된다.
해당 정보를 기반으로 userDetails User객체를 생성하여 리턴한다.
로그인 API
AuthController
@RestController
@RequestMapping("/api")
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
this.tokenProvider = tokenProvider;
this.authenticationManagerBuilder = authenticationManagerBuilder;
}
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
TokenProvider, AuthenticationManagerBuilder를 주입받음
로그인 API 경로 : /api/authenticate -> Post요청
LoginDto의 username, password를 파라미터로 받고, 이를 이용해 UsernamePasswordAuthenticationToken을 생성한다.
authentication Token을 이용해서 Authentication 객체를 생성하려고 authenticate 메소드가 실행될때, loadUserByUsername 메소드가 실행된다.
JWT Token을 Response 헤더에도 넣어주고, TokenDto를 이용해서 Response Body에도 넣어서 리턴하게 된다.
회원가입 API, 권한검증 확인
SecurityUtil
public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {}
public static Optional<String> getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
logger.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
getCurrentUsername() : Security Context의 Authentication 객체를 이용해 username을 리턴해주는 간단한 유틸성 메소드
Security Context에 Authentication객체가 저장되는 시점은 JwtFilter의 doFilter메소드에서 Request가 들어올 때이고, SecurityContext에 Authentication 객체를 저장해서 사용하게 됨
UserService
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public UserDto signup(UserDto userDto) {
if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
}
Authority authority = Authority.builder()
.authorityName("ROLE_USER")
.build();
User user = User.builder()
.username(userDto.getUsername())
.password(passwordEncoder.encode(userDto.getPassword()))
.nickname(userDto.getNickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
return UserDto.from(userRepository.save(user));
}
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) {
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
}
@Transactional(readOnly = true)
public UserDto getMyUserWithAuthorities() {
return UserDto.from(
SecurityUtil.getCurrentUsername()
.flatMap(userRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new NotFoundMemberException("Member not found"))
);
}
}
UserRepository, PasswordEncoder를 주입받는다.
signup() : 회원가입 로직
username이 DB에 존재하지 않으면, Authority, User 정보를 생성해서 UserRepository의 save 메소드를 통해 DB에 정보를 저장함.
유저 권한 정보를 가져오는 메소드
getUserWithAuthorities -> username을 기준으로 정보를 가져옴
getMyUserWithAuthorities -> SecurityContext에 저장된 username의 정보만 가져온다.
UserController
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
@PostMapping("/signup")
public ResponseEntity<UserDto> signup(
@Valid @RequestBody UserDto userDto
) {
return ResponseEntity.ok(userService.signup(userDto));
}
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')")
public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
return ResponseEntity.ok(userService.getMyUserWithAuthorities());
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')")
public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(userService.getUserWithAuthorities(username));
}
}
signup() : UserDto를 매개변수로 받아서 UserSerivce signup 메소드 호출
getMyUserInfo() : @PreAuthorize를 통해 User, Admin 두 가지 권한 모두 허용
getUserInfo(): admin 권한만 호출할 수 있도록 설정
userService에서 만들었던 username 파라미터를 기준으로 유저 정보와 권한 정보를 리턴하는 API가 됨
<참고 자료>
Spring boot JWT Tutorial 정은구님 강의영상 및 자료 참조
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard
https://bcp0109.tistory.com/301
'Java > Spring' 카테고리의 다른 글
[Spring] 변경 감지(Dirty checking)와 병합(merge) (0) | 2023.03.08 |
---|---|
[Spring] 애브리타임 게시판 부가기능 추가(댓글, 쪽지, 좋아요, 즐겨찾기, 페이징처리) (0) | 2023.02.11 |
[Spring] Rest vs RestAPI (0) | 2022.11.23 |
[Spring] MVC Pattern (2) | 2022.11.23 |
[Spring] Bean Scope, MVC Framework, SpringApplication(Spring Boot) (0) | 2022.10.27 |