SpringSecurity
SecurityFilterChain은 수많은 필터로 이루어져 있다.
로컬 로그인의 인증 구현을 위해 전반적인 인증 로직 및 UsernamePasswordAuthenticationFilter을 자세히 보면 된다.
Authenticaiotn Flow
전반적인 인증 흐름은 위와 같다.
- 로그인 요청
- 로그인 인증 요청이면 UsernamePasswordAuthenticationFilter가 요청을 가로 채 id, pw를 이용한 UsernamePasswordAuthenticationToken인증 객체를 만든다.
- AuthenticationManager에 인증을 위임한다.
- AuthenticaitonManager을 구현한 ProvicerManager은 시큐리티에서 기본적으로 제공하는 AuthenticationProvider에 인증객체를 전달한다.
- AuthenticationProvider에서 UserDetailService에 인증객체를 전달해 주면 해당 객체의 정보들로 DB에서 일치하는 사용자 정보를 찾아 UserDetails 객체를 생성한다.
- AuthticationProvicer은 UserDetails와 UsernamePasswordAuthenticationToken비교하여 인증에 성공하면 ProviderManager에 권한을 담은 새로운 UsernamePasswordAuthenticationToken을 생성한다.
- UsernamePasswordAuthenticationFilter는 검증된 인증 객체를 SecurityContextHolder의 SecurityContext에 저장한다.
- 참고사항
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().configurationSource(corsConfigurationSource())
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler);
http.sessionManagement()
/**세션 방식 해제*/
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.httpBasic().disable()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
.antMatchers("/**","/login").permitAll()
/**인증 된 사용자만 사용 가능*/
.anyRequest().authenticated();
http.headers()
.frameOptions().disable();
/**시큐리티 로그아웃 해제 */
http.logout().disable();
/**jwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter보다 먼저 실행*/
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
...
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Jwt로 인증을 진행할 것 이므로 Security의 기본 설정인 Session방식을 해제시키고 jwt filter을 우선순위로 올려준다.
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
로그아웃도 세션방식이 아닌 자체적으로 구현 할 것 이므로 disable 시킨다.
http.logout().disable();
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
패스워드 암호화를 위한 PasswordEncoder 를 빈으로 등록해야 된다. 스프링부트 2.6 이상부터 WebSecurityConfig 에 빈을 등록할 경우 커스터마이징한 AuthenticationProvider 와 순환 참조 에러가 발생하므로 가급적 다른 Config 클래스에 빈을 추가하는 것이 좋다.
로컬로그인
@Override
public GetJwtDto login(PostLoginDto postLoginDto) {
// 1. Login ID/PW를 기반으로 Authentication 객체 생성
// 인증을 위한 객체로 아직 권한이 없는 상태 : isAuthentication = false
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken(postLoginDto.getAuthKey, postLoginDto.getPwd);
// 2. 실제 검증 (사용자 비밀번호 체크)이 실행되는 부분
// authenticate 매서드가 실행 될 때 (Custom)UserDetailService 의 loadUserByUsername 매서드 실행
authenticationManagerBuilder.getObject().authenticate(authentication);
// 3. 인증 정보 기반으로 JWT 토큰 생성
final JwtToken jwtToken = jwtTokenProvider.generateToken(postLoginDto.getAuthKey());
// RefreshToken Redis 저장
redisUtil.set("RT:" + jwtToken.getJwtRefreshToken(), postLoginDto.getAuthKey(), 1000L * 60 * 60 * 24 * 14);
return GetJwtDto.builder()
.accessToken(jwtToken.getJwtAccessToken())
.refreshToken(jwtToken.getJwtRefreshToken())
.build();
}
- Login 요청으로 들어온 ID, PW 기반으로 인증을 위한 Authentication 객체를 생성한다.
- authenticate 메서드를 통해 요청된 사용자에 대한 실제 검증이 진행된다.
- 검증이 오류없이 완료 되었다면 Login 요청으로 들어온 ID를 기반으로 JWT 토큰을 생성한다.
authenticate 메서드 흐름
- 실제 인증을 위해 authenticate() 메서드가 실행 | 해당 메서드 → AuthenticationManager interface 를 구현한 ProviderManager class 메서드임
- ProviderManager의 authenticate() 메서드는 모든 provider 중에서 해당 인증을 처리할 수 있는 provider를 찾아, 실제 인증 절차인 authenticate 메서드 실행함
- 이때 인증을 처리할 수 있는 provider는 AbstractUserDetailsAuthenticationProvider이고 authenticate() 메서드 로직 중 일부는 DaoAuthenticationProvider에 실제로 구현되어 사용함
- AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 통해 로그인 요청된 id, pw에 대한 인증 처리
더보기
왜 authentication 객체로 JWT 토큰을 생성하지 않나?
- authentication 과 같이 인증 정보가 담긴 것 보단 노출되기 쉬운 ID값을 담는 것이 더 낫다고 판단했기 때문
@Service
@RequiredArgsConstructor
public class CustomUserDetailServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByAuthKey(username)
.orElseThrow(() -> new UnauthorizedException(ErrorCode.INVALID_TOKEN));
return new CustomUserDetails(user,Collections.singleton(new SimpleGrantedAuthority(user.getRole().getViewName())));
}
}
JWT
JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
try {
String token = parseBearerToken(request);
if (token != null && jwtUtil.validateToken(token)) {
Authentication authentication = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.error("Could not set user authentication in security context", e);
setResponse(response, ErrorCode.USER_NOT_FOUND);
}
chain.doFilter(request, response);
}
/**
* Request Header 에서 토큰 정보 추출
*/
private String parseBearerToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return bearerToken;
}
JwtUtil
@Component
@RequiredArgsConstructor
public class JwtUtil {
@Value("${JWT_SECRET_KEY}")
private String secretKey;
@Value("${AUTHORITIES_KEY}")
private String AUTHORITIES_KEY;
private final CustomUserDetailServiceImpl customUserDetailService;
public Key getSecretKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
/**토큰 유효성 확인*/
/**
* JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
*/
public Authentication getAuthentication(String token) {
/** 토큰 복호화*/
/** 사용자 속성값*/
Claims claims = parseClaims(token);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new UnauthorizedException(ErrorCode.INVALID_AUTH_TOKEN);
}
/**userDetails 반환*/
CustomUserDetails userDetails = (CustomUserDetails) customUserDetailService.loadUserByUsername(claims.getSubject());
return new UsernamePasswordAuthenticationToken(userDetails.getUser(), null,
userDetails.getAuthorities());
}
public Long getExpiration(String token) {
Date dueTime = Jwts.parserBuilder().setSigningKey(getSecretKey()).build()
.parseClaimsJws(token)
.getBody().getExpiration();
Long now = new Date().getTime();
return dueTime.getTime() - now;
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(getSecretKey()).build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtTokenProvider
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${JWT_SECRET_KEY}")
private String secretKey;
@Value("${AUTHORITIES_KEY}")
private String AUTHORITIES_KEY;
/**
* 토큰 유효 시간
* 60분, 2주
*/
private static final long JWT_EXPIRATION_TIME = 1000L * 60 * 60;
private static final long REFRESH_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 14;
public Key getSecretKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
/**
* 토큰 생성
*/
public JwtToken generateToken(String authKey)
throws HttpServerErrorException.InternalServerError {
final Date now = new Date();
return JwtToken.builder()
.jwtAccessToken(generateAccessToken(authKey, now))
.jwtRefreshToken(generateRefreshToken(now))
.build();
}
public String generateAccessToken(String authKey, Date now) {
final Date accessTokenExpire = new Date(now.getTime() + JWT_EXPIRATION_TIME);
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("Campper")
.setIssuedAt(now) /**생성 일자*/
.setSubject(authKey)
.claim(AUTHORITIES_KEY, authKey) /**권한 설정*/
.setExpiration(accessTokenExpire) /**만료 일자*/
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(Date now) {
final Date refreshTokenExpire = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME);
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("Campper")
.setExpiration(refreshTokenExpire)
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
.compact();
}
}
https://wildeveloperetrain.tistory.com/57
'💻 프로젝트 > 🏕 캠퍼' 카테고리의 다른 글
[Campper] Redis template 사용하여 용량 개선하기 (1) | 2023.10.15 |
---|---|
[Campper] Spring Cloud Feign 사용하여 공공데이터 쉽게 관리하기 (0) | 2023.10.09 |