Spring Security + JWT 1편
https://hse06028.tistory.com/214
프로젝트 생성
build.gradle
dependencies{
...
implementation 'org.springframework.boot:spring-boot-starter-security'
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
}
application.properties
...
jwt.auth = Authorization
jwt.secret =
jwt.token-validity-in-seconds = 3600
...
- secret: jwt를 암호화할 때 사용하는 시크릿 키로 사용된다. JWT를 생성하는데 매우 중요한 정보이기 때문에 해당 정보가 public하게 노출되면 안된다.
- token-validity-in-seconds: token의 유효 시간을 기재한다.
User
유저 엔티티. 유저의 정보들과 유저가 로그인 할 때 사용하는 email과 password로 이루어져 있다.
User.java
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User (String name, String email, String password, Role role) {
this.name = name;
this.email = email;
this.password = password;
this.role = role;
}
}
Role.java
@Getter
@RequiredArgsConstructor
public enum Role {
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String value;
회원이 일반 사용자만 있도록 만들어 주었다.
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
email을 활용하여 조회하는 메소드만 추가하였다.
UserService.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public UserMainResponseDto signup(UserSaveRequestDto requestDto) {
if (userRepository.findByEmail(requestDto.getEmail()).orElse(null) != null) {
throw new RuntimeException("이미 가입된 유저입니다.");
}
User user = User.builder()
.email(requestDto.getEmail())
.password(passwordEncoder.encode(requestDto.getPassword()))
.name(requestDto.getName())
.role(Role.ROLE_USER)
.build();
userRepository.save(user);
return new UserMainResponseDto(user);
}
public UserMainResponseDto findById(Long id) {
return userRepository.findById(id)
.map(user -> new UserMainResponseDto(user))
.orElseThrow(InvalidateUserException::new);
}
public UserMainResponseDto findUserInfo() {
String email = findCurrentUserEmail();
return userRepository.findByEmail(email)
.map(user -> new UserMainResponseDto(user))
.orElseThrow(InvalidateUserException::new);
}
private String findCurrentUserEmail(){
String email = SecurityUtil.getCurrentEmail()
.orElseThrow(EmptyAuthenticationException::new);
return email;
}
}
User에 대한 메소드들을 작성해줬다.
- signup: login 시도한 email과 password가 담긴 UserSaveRequestDto를 매개변수로 받는다. 전달받은 email을 조회하여 가입되지 않은 유저이면 해당 정보를 기반으로 가입을 진행한다.
- findById: 전달받은 user id를 활용하여 조회한다. 조회되는 user가 없다면 예외를 던진다.
- findUserInfo: 후에 작성할 SecurityUtil.getCurrentEmail()에서 SecurityContext안에 담긴 user id 정보를 가져온다. 해당 id를 기반으로 조회하여 반환한다. 여기서 user id는 위에서 작성한 User Entity의 email에 해당한다.
UserController.java
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("signup")
public ResponseEntity<? extends BasicResponse> signup(@Valid @RequestBody UserSaveRequestDto requestDto) {
return ResponseEntity.ok(new CommonResponse<>(userService.signup(requestDto)));
}
}
- signup: 입력받은 requestDto를 검증하여 service에 전달한다.
JWT 설정
JwtTokenProvider.java
@Slf4j
@Component
public class JwtTokenProvider implements InitializingBean {
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
// 빈이 생성이 되고 의존성 주입이 되고 난 후에 주입받은 secret 값을 Base64 Decode 해서 key 변수에 할당
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드 추가
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();
}
// token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성
public Authentication getAuthentication(String token) {
// token을 활용하여 Claims 생성
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
List<SimpleGrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// claims과 authorities 정보를 활용해 User (org.springframework.security.core.userdetails.User) 객체 생성
User principal = new User(claims.getSubject(), "", authorities);
// Authentication 객체를 리턴
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰의 유효성 검증을 수행하는 validateToken 메소드 추가
public boolean validateToken(String token) {
try {
// 토큰을 파싱해보고 발생하는 exception들을 캐치, 문제가 생기면 false, 정상이면 true를 return
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
- @Component: 스프링 빈으로 등록한다.
- JwtTokenProvider(@Value("${jwt.secret}") String secret, ... ): application.properties에 명시한 정보를 주입받아 생성하는 생성자이다.
- void afterPropertiesSet(): InitializingBean을 구현하여 override하였다. 빈이 생성이 되고 의존성 주입이 되고 난 후에 주입받은 secret 값을 Base64 Decode 해서 key 변수에 할당하기 위해 사용하였다.
- String createToken(Authentication authentication): Authentication 객체의 권한정보를 이용해서 토큰을 생성하여 반환해준다. 단순히 AccessToken만 반환한다.
- Authentication getAuthentication(String token): token에 담겨있는 정보를 이용해 Authentication 객체를 반환한다.
- boolean validateToken(String token): 유효한 token인지 검증하는 메소드이다.
JwtFilter.java
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter implements Filter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public final JwtTokenProvider jwtTokenProvider;
// 실제 필터링 로직은 doFilter 내부에 작성 jwt 토큰의 인증 정보를 SecurityContext에 저장하는 역할
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
// request에서 jwt 토큰 정보 추출
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
// token 유효성 검증에 통과하면
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
Authentication authentication = jwtTokenProvider.getAuthentication(jwt); // 정상 토큰이면 SecurityContext 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
chain.doFilter(request, response);
}
// request header에서 토큰 정보를 꺼내오는 메소드
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Filter 인터페이스를 구현한 JwtFilter이다.
- void doFilter(...): HttpServletRequest에서 JWT 정보를 추출하여 앞서 생성한 검증 메소드를 통해 검증한다. 통과하면 SecurityContext에 해당 정보를 가진 Autentication 객체를 저장한다.
- String resolveToken(...): request header에서 토큰 정보를 꺼내온다.
JwtSecurityConfig.java
@Component
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final JwtFilter jwtFilter;
// JwtFilter를 Security로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
// Security 로직에 필터를 등록
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
SecurityConfigurer<DefualtSecurityFilterChain, HttpSecurity> 인터페이스를 구현한 JwtSecurityConfig이다. JwtFilter를 Security 로직에 등록한다.
Spring Security는 각각 역할에 맞는 필터들이 체인 형태로 구성되어 순서에 맞게 실행된다. 특정한 필터를 생성하여 등록하기 위해서는 addFilterBefore을 활용하여 사용한다.
JwtAuthenticationEntryPoint.java
// 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
// 유효한 자격증명을 제공하지 않고 접근하려 할 때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
JwtAccessDeniedHandler.java
// 필요한 권한이 존재하지 않는 경우 403 Forbidden 에러를 리턴
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할 때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
Security 설정
SecurityConfig.java
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true) //@PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해 사용
@EnableWebSecurity // 스프링 시큐리티가 스프링 체인 필터에 등록. 기본적인 web 보안 활성화
public class SecurityConfig{
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtSecurityConfig jwtSecurityConfig;
private final JwtFilter jwtFilter;
private static final String [] PERMIT_URL_ARRAY = {
"/v3/api-docs/**",
"/swagger-ui/**"
};
// password encoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return
http
.cors().disable()
.formLogin().disable()
.headers().frameOptions().disable()
.and()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests() //HttpServeletRequest를 사용하는 요청들에대한 접근제한을 설정
.antMatchers(PERMIT_URL_ARRAY).permitAll()
.antMatchers("/api/signup").permitAll()
.antMatchers("/api/signin").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) //OAUTH2 사용한다면 변경 필요
// Exception을 핸들링할 때 직접 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and().build();
}
}
WebSecurityConfigurerAdapter 인터페이스를 구현한 구현체이다. Spring Security에 필요한 설정들을 적절히 추가한다.
- @EnableWebSecurity: 웹 보안을 활성화 한다.
- @EnableGlobalMethodSecurity: @PreAuthorize, @PostAuthorize 애노테이션을 사용하기 위해 추가
- passwordEncoder: password encoder
SecurityUtil.java
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SecurityUtil {
public static Optional<String> getCurrentEmail() {
// Security Context에 Authentication 객체가 저장되는 시점은
// JwtFilter의 doFilter메소드에서 Request가 들어올 때 SecurityContext에 Authentication 객체를 저장해서 사용
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
log.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);
}
}
SecurityContext에 Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter메소드가 실행될 때이다.
SecurityContext에서 Authentication 객체를 get 한다.
그렇게 얻은 authentication 객체가 정상적인 값이 들어 있다면 username을 꺼내와 반환한다.
Login
AuthController.java
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signin")
public ResponseEntity<TokenResponseDto> authorize(@Valid @RequestBody LoginDto loginDto) {
TokenResponseDto tokenResponseDto = authService.login(loginDto);
// 1. Response Header에 token 값을 넣어준다.
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + tokenResponseDto.getToken());
// 2. Response Body에 token 값을 넣어준다.
return new ResponseEntity<>(tokenResponseDto, httpHeaders, HttpStatus.OK);
}
}
AuthService.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public TokenResponseDto login(UserLoginDto loginDto) {
// username, password를 파라미터로 받고 이를 이용해 UsernamePasswordAuthenticationToken을 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
// authenticationToken을 이용해서 Authenticaiton 객체를 생성하려고 authenticate 메소드가 실행될 때
// CustomUserDetailsService에서 override한 loadUserByUsername 메소드가 실행된다.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// authentication 을 기준으로 jwt token 생성
String jwt = tokenProvider.createToken(authentication);
return new TokenResponseDto(jwt);
}
}
사용자가 입력한 login 정보를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성한다. 아직 인증이 완료되지 않은 객체이기 때문에 authenticationManagerBuilder를 활용하여 AuthenticationManager의 구현체인 ProviderManger의 authenticate 메소드를 실행하여 검증 후 Authenication 객체를 받는다.
해당 정보를 기준으로 JWT를 생성하여 반환해준다.
CustomUserDetailsService.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// DB에서 유저정보를 가져온다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(user -> createUserDetails(user))
.orElseThrow(() -> new UsernameNotFoundException(username + " 존재하지 않는 username 입니다."));
}
// DB에서 조회한 user 정보를 기반으로 UserDetails의 구현체인
// User (org.springframework.security.core.userdetails.User) 를 생성하여 return 한다.
private UserDetails createUserDetails(User user) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().toString());
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
UserDetailsService 인터페이스를 구현한 클래스이다. 위에서 언급한 authenticate 메소드가 실행될 때 loadUserByUsername을 통하여 DB정보를 가져온 후 사용자의 PW가 일치하는지 검증한다. UserDetails 객체를 반환하기 위해 createUserDetails 메소드에서 User entity의 정보를 기반으로 org.springframework.security.core.userdetails.User를 생성하여 반환한다.
* UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken은 Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로, User의 ID가 Principal 역할을 하고, Password가 Credential의 역할을 한다. UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두번째는 인증이 완료된 객체를 생성한다.
참고자료