Request Header 에서 가져온 토큰을 필터링하는 과정을 담당
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final TokenProvider tokenProvider;
/**
* 실제 필터링 로직은 doFilter 내부에 작성 jwt 토큰의 인증 정보를 SecurityContext에 저장하는 역할.
*/
@Override
public void doFilter(ServletRequest request,ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 1~7. Request 객체에서 담겨져 온 토큰을 조회
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
// # 2. TokenProvider 참고
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
// 8. Provider에 의해 인증 절차를 진행. 인증이 완료되면 Authentication 객체를 리턴
Authentication authentication = tokenProvider.getAuthentication(jwt);
// 9. Authentication 객체를 SecurityContextHolder에 저장
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;
}
}
토큰 생성 및 유효성 검사, Authentication 객체 생성을 담당
@Slf4j
@Component
public class TokenProvider implements InitializingBean {
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;
}
/**
* 빈이 생성이 되고 의존성 주입이 되고 난 후에 주입받은 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 userName) {
String authorities = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
Claims claims = Jwts.claims()
.setSubject(authentication.getName())
.setExpiration(validity);
claims.put(AUTHORITIES_KEY, authorities);
claims.put(NAME_KEY, userName);
return Jwts.builder()
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
/**
* token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성.
*/
public Authentication getAuthentication(String token) {
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());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* 토큰의 유효성 검증을 수행하는 validateToken 메소드 추가.
*/
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 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
번호 6. 참고
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// DB 에 저장된 사용자 정보와 일치하는지 여부를 판단
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findById(Integer.parseInt(username))
.orElseThrow(() -> new UsernameNotFoundException(username + " 존재하지 않는 username 입니다."));
return createUserDetails(user);
}
private UserDetails createUserDetails(User user) {
return new org.springframework.security.core.userdetails.User(
String.valueOf(user.getId()),
user.getPassword(),
List.of(new SimpleGrantedAuthority(user.getRole().toString()))
);
}
}
위 구현 클래스 외에도, Spring Security를 사용하기 위한 Config 클래스도 필요함
++ 추가 예시
UserDetailsServiceImpl
@Service // 빈으로 사용할 것이다
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
//UserDetailsServiceImpl 은 DB 에서 user 를 조회하고, 인증한 다음, UserDetails 를 반환하고, UserDetails 를 사용해서 인증 객체를 만든다
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("UserDetailsServiceImpl.loadUserByUsername : " + username);
//user 를 DB 에서 조회
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
//DB 에서 조회를 해온 user, username, password 를 User 객체에 담아주면서 UserDetailsImpl 를 반환
return new UserDetailsImpl(user, user.getUsername(), user.getPassword());
}
}
UserDetailsImpl
public class UserDetailsImpl implements UserDetails {
//인증이 완료된 사용자 추가----------------------------------------------------
private final User user; // 인증완료된 User 객체 --> user 를 담는다
private final String username; // 인증완료된 User의 ID
private final String password; // 인증완료된 User의 PWD
//생성자
public UserDetailsImpl(User user, String username, String password) {
this.user = user;
this.username = username;
this.password = password;
}
// 인증완료된 User 를 가져오는 Getter
//getUser: user 를 가져온다
public User getUser() {
return user;
}
//----------------------------------------------------------------------
//사용자의 권한 GrantedAuthority 로 추상화 및 반환---------------------------------------------------
//권한을 가지고 오는 부분
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole(); //1. 유저의 권한을 가져와서(user.getRole())
String authority = role.getAuthority(); //2. 그것(role)을 String 값으로 만들고
//3. 추상화해서 사용
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
//----------------------------------------------------------------------------------------------
//사용자의 ID, PWD Getter--------------------------------------------------------------------------
//기본적으로 이렇게 반환값이 default 로 설정되는데, username 과 password 를 사용해야하므로(this.username = username; 이런 부분) 다르게 설정해줌
@Override
public String getUsername() { //username 가져온다.
return this.username; //반환값은 기본적으로 null
}
@Override
public String getPassword() { //password 가져온다
return this.password;
}
//-----------------------------------------------------------------------------------------------
@Override
public boolean isAccountNonExpired() {
return false; //기본적으로 이렇게 반환값이 default 로 설정되는데, username 과 password 를 사용해야하므로 다르게 설정해줌
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
JwtFilter를 SecurityConfig에 적용할 때 사용
@Configuration
@EnableWebSecurity
@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);
}
}
Spring Security 관련 설정 파일
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtSecurityConfig jwtSecurityConfig;
/**
* 암호화 방식 선택
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 어플리케이션 자체에 넘어오는 요청에 대한 인증, 인가 관련 설정에 대한 메소드.
* 이 위치에서 제외된 API들은 Spring Security의 검증 대상 자체에서 제외됩니다.
*/
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/chat/health/check")
.antMatchers("/ws/send/message")
.antMatchers("/ws/connect");
}
/**
* API 접근에 대한 인증 처리 관련 설정.
* 선택적으로 Spring Security에 의한 인증, 인가 절차 대상 및 방법을 설정할 수 있습니다.
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 인가 절차를 생략할 API를 지정
.and()
.authorizeRequests()
.antMatchers("/user/signup", "/user/login").permitAll()
// 그 외 API는 인증 절차 수행
.anyRequest().authenticated()
// JwtSecurityConfig 클래스 적용
.and()
.apply(jwtSecurityConfig);
}
}
++ 추가 예시 : Spring Security 관련 설정 + 권한 설정
WebSecurityConfig (config 패키지로 분류했음)
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true) // TestController 에서의 @Secured 어노테이션 활성화
public class WebSecurityConfig {
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandle customAccessDeniedHandler;
private final UserDetailsServiceImpl userDetailsService;
// 비밀번호 암호화 기능 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); //BCryptPasswordEncoder: BCrypt 형식의 Password --> 적응형 단방향이 자동 적용됨
}
@Bean
//WebSecurityCustomizer 은 SecurityFilterChain 보다 우선적으로 걸리는 설정
public WebSecurityCustomizer webSecurityCustomizer() {
// h2-console 사용 및 resources 접근 허용 설정
//ignoring(): 이러한 경로도 들어온 것들은 인증 처리하는 것을 무시하겠다
return (web) -> web.ignoring()
//아래의 것들(아래의 ("/h2-console/**") 이런 URL 들)을 한번에 설정해줄 수 있음
.requestMatchers(PathRequest.toH2Console())
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Bean
//Security 는 모든 요청을 다 인증하기 때문에, ("/h2-console/**") 이런 것 들을 일일이 다 인증할 수 가 없다
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf().disable();
http.authorizeRequests().antMatchers("/api/user/**").permitAll()
// .antMatchers("/h2-console/**").permitAll() //그래서, permitAll() 를 사용해서 ("/h2-console/**") 이런 URL 들을 인증하지 않고 실행 할 수 있게 함
// .antMatchers("/css/**").permitAll()
// .antMatchers("/js/**").permitAll()
// .antMatchers("/images/**").permitAll()
// .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
//추가) 이런 설정도 가능함
//.antMatchers(HttpMethod.GET, "/api/user").hasRole()
//그 이외의 URL 요청들을 전부 다 authentication(인증 처리)하겠다
.anyRequest().authenticated();
// Custom 로그인 페이지 사용
// Security 에서 제공하는 default Form Login 을 사용하겠다
// loginPage("/api/user/login-page").permitAll(): Custom 로그인 페이지 사용
//http.formLogin().loginPage("/api/user/login-page"): Form Login 방식에서 인증이 되지 않는 요청을 로그인 페이지로 보낼 때, 기존의 로그인 페이지가 아닌, 우리가 custom 한 로그인 페이지를 반환하는 URL 로 요청 되어짐
//permitAll(): 이 요청은 다 허가해주겠다
http.formLogin().loginPage("/api/user/login-page").permitAll();
// Custom Filter 등록하기
//addFilterBefore: 어떤 Filter 이전에 추가하겠다 --> 우리가 만든 CustomSecurityFilter 를 UsernamePasswordAuthenticationFilter 이전에 실행할 수 있도록
//CustomSecurityFilter = 우리가 custom 한 SecurityFilter 를 사용하기 때문에, JWT 토큰을 검증하는 추가적인 Filter 가 필요
//1. CustomSecurityFilter 를 통해 인증 객체를 만들고 --> 2. context 에 추가 --> 3.인증 완료 --> UsernamePasswordAuthenticationFilter 수행 --> 인증됐으므로 다음 Filter 로 이동 --> Controller 까지도 이동
http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);
// "거부"가 났을 때, 403 Forbidden 페이지(접근 제한 페이지) 이동 설정 --> 왜 주석 처리? 이걸 하면, 이 코드가 우선적으로 잡혀서 밑에 403 쪽 처리가 되지 않아서
// http.exceptionHandling().accessDeniedPage("/api/user/forbidden");
// 401 Error 처리, Authorization 즉, 인증과정에서 실패할 시 처리
http.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint);
// 403 Error 처리, 인증과는 별개로, 추가적인 권한이 충족되지 않는 경우
http.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler);
return http.build();
}
}
TestController
@Controller
@RequestMapping("/api")
public class TestController {
@Secured(value = UserRoleEnum.Authority.ADMIN) // WebSecurityConfig에서 @EnableGlobalMethodSecurity(securedEnabled = true)으로 인해 사용 가능
@PostMapping("/test-secured")
public String securedTest(@AuthenticationPrincipal UserDetails userDetails) {
System.out.println("*********************************************************");
System.out.println("UserController.securedTest");
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
System.out.println("*********************************************************");
return "redirect:/api/user/login-page";
}
}
UserController
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
// ADMIN_TOKEN
//ADMIN 인지 user 인지 확인하기 위함(빠른 서비스를 위해, 저번 프로젝트와는 달리 controller 에서 처리하도록 함)
private static final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";
@GetMapping("/signup")
public ModelAndView signupPage() {
return new ModelAndView("signup");
}
@GetMapping("/login-page")
public ModelAndView loginPage() {
return new ModelAndView("login");
}
@PostMapping("/signup")
public String signup(SignupRequestDto signupRequestDto) {
String username = signupRequestDto.getUsername();
String password = passwordEncoder.encode(signupRequestDto.getPassword());
// 회원 중복 확인
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
}
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (signupRequestDto.isAdmin()) {
if (!signupRequestDto.getAdminToken().equals(ADMIN_TOKEN)) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
User user = new User(username, password, role);
userRepository.save(user);
return "redirect:/api/user/login-page";
}
@PostMapping("/login")
//@AuthenticationPrincipal: 인증 객체(Authentication)의 Principal 부분의 값을 가져온다
//UserDetails userDetails: Filter(CustomSecurityFilter)에서 인증 객체를 만들 때, Principal 부분에 userDetails 를 넣었기 때문에, userDetails 를 파라미터로 받아올 수 있었음
//userDetails 안에는 user, password 데이터가 들어가있는 상태
public String login(@AuthenticationPrincipal UserDetails userDetails) {
System.out.println("*********************************************************");
System.out.println("UserController.login");
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
System.out.println("*********************************************************");
return "redirect:/api/user/login-page";
}
//"거부"가 났을 때, 403 Forbidden 페이지 적용
@PostMapping("/forbidden")
public ModelAndView forbidden() {
return new ModelAndView("forbidden");
}
}
UserRoleEnum
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
//"권한 (Authority)" 설정(권한 1개 이상 설정 가능)
public static class Authority {
public static final String USER = "ROLE_USER"; //"USER" 권한 부여 ("권한 이름" 규칙: "ROLE_" 로 시작하게 만들겠다)
public static final String ADMIN = "ROLE_ADMIN"; //"ADMIN" 권한 부여
}
}
++ 추가 예시 : 인증과정에서 실패할 시 처리
CustomAuthenticationEntryPoint (WebSecurityConfig 참고)
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final SecurityExceptionDto exceptionDto =
new SecurityExceptionDto(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
@Override
//401, 403 에러가 발생하면, commence() 함수 실행 --> 만드는 값들이 Client 쪽으로 반환됨
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authenticationException) throws IOException {
//response 에 ContentType, Status 를 넣음
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
//ObjectMapper 를 사용해서, String 값으로 변환 --> Client 쪽으로 반환됨
try (OutputStream os = response.getOutputStream()) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.writeValue(os, exceptionDto);
os.flush();
}
}
}
style.css 를 넣을 경로(폴더)를 만들지 않아서 css가 적용되지 않았음
could not resolve all files for configuration ':compileclasspath'.
...
could not find org.thymeleaf.extras:thymeleaf-extras-springsecurity6
...
이런 에러 발생
--> 해결법
spring boot 버전을 3.0.0 에서 2.7.6 으로 낮춰서 생성