스프링 시큐리티
스프링 시큐리티의 전체적인 흐름
password config 설정
security config 설정
filter
UserDetailsService
스프링 시큐리티는 자바의 프레임워크 스프링의 또다른 프레임워크이다.
사용 목적 및 장점
스프링 시큐리티의 사용 목적 및 장점은 인증, 인가가 필요한 부분을 디스패쳐 서블릿 으로 넘겨
비즈니스 로직을 담당하는 Service 에서 처리할 필요 없이 디스패쳐 서블릿 으로 넘기기 전 스프링 시큐리티를 통해 인증, 인가를 처리할 수 있다. 이로 인해 Service 부분에서 로그인 및 인증, 인가 처리를 할 필요 없이 비즈니스 로직에만 집중할 수 있다.
로직 분리
또한 스프링 시큐리티에서 기본적으로 제공하는 여러 필터를 통해 보안을 강화할 수 있으며,
개발자가 커스텀하게 필터를 만들어 유연하게 대처할 수 있다.
보안 강화
//gradle의 경우 추가 방법 spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
config설정import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
......생략
}
@configuration 어노테이션의 경우 스프링이 실행될때 이 어노테이션이 달려있는 클래스를 기반으로 내부에 작성된 빈(Bean) 을 생성하여 의존성 주입을 시켜준다.
일종의 파일간 의존성 주입 및 설정
@EnableWebSecurity 어노테이션은 스프링 시큐리티에서 지원하는 어노테이션으로 @Configuration 과 같이 사용하고 시큐리티 구성을 설정할 수 있다.
그러나 jwt 커스텀 필터만을 사용했을때는 저 어노테이션이 없어도 정상적으로 작동하기는 한다.
스프링 시큐리티를 명시적, 사실적으로 표현하고 관련 구성을 설정
public class JwtAuthorizationFilter extends OncePerRequestFilter {
......
}
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
......
}
다음으로는 Config안에서 설정한 내용을 바탕으로 필터를 만든다.
위 코드를 기준으로는 Jwt토큰 에 대한
인증(유효한 토큰인지), 인가(아이디, 비밀번호 로그인의 경우 맞다면 토큰 발행) 에 대한 로직을 수행한다.
더 자세한 내용은 밑에서 정리
이렇게 시큐리티 Config안에 설정된 순서대로 필터를 거르고 인증을 받았다면 디스패쳐 서블릿을 통해 Controller까지 도달 할 수 있고 그렇지 않다면 다시 클라이언트에게 인증 거부 처리와 함께 응답이 돌아간다.
password에 대한 설정도 필요하다.
사용자가 회원가입을 할때 비밀번호를 그대로 DB에 저장하지 않고 해시화 시켜서 저장한다.
해시화 하지 않는다면 DB에 접근할 수 있는 개발자 혹은 사용자들은 다른 사용자들의 비밀번호를 알 수 있게 되고,
개발자들 또한 계속 DB의 내용을 보다보면 자신도 모르게 외울수 있게 되기 때문이다.
이때 가장 많이 사용하는 암호화 방식은 bcrypt 암호화 방법으로 16진수로 암호화 시켜준다.
이 해시화는 복호화 할 수 없다.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {//PasswordEncoder라는 인터페이스의 구현체 BCryptPasswordEncoder
return new BCryptPasswordEncoder(); //BCrypt는 가장 많이 사용되는 hash함수
}
}
@Configuration 을 사용하여 스프링 컨테이너를 통해 빈에 대한 의존성 주입을 맡기고
스프링 시큐리티에서 제공하는 PasswordEncoder 인터페이스에 대한 구현체를 등록한다.
이 PasswordEncoder 는 나중에 클라이언트에서 아이디, 비밀번호를 로그인을 시도할때 UsernamePasswordAuthenticationFilter 에서 해당 PasswordEncoder 를 사용한다.
위에서 언급한 EnableWebSecurity를 설정한 Config 파일에서의 설정이다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
//jwt관련 유틸들
private final JwtUtils jwtUtils;
//Authentication매니저가 사용할 UserDetailsService 등록
private final MemberDetailServiceCustom memberDetailServiceCustom;
//Authentication매니저를 설정해 놓은 클래스
private final AuthenticationConfiguration authenticationConfiguration;
//의존성 주입
public WebSecurityConfig(JwtUtils jwtUtils, MemberDetailServiceCustom memberDetailServiceCustom, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtils = jwtUtils;
this.memberDetailServiceCustom = memberDetailServiceCustom;
this.authenticationConfiguration = authenticationConfiguration;
}
//Authentication매니저에 를 AuthenticationConfiguration에서 뽑아옴
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
//따로 커스텀하게 설정해놓은 인증 필터 사용시 Authentication매니저를 사용하게 설정
//UsernamePasswordAuthenticationFilter를 extends하여 사용하기 때문
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtils);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
//jwt토큰 인가 필터 빈 등록
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtils, memberDetailServiceCustom);
}
//클라이언트의 http 요청에 대해 처리할 필터, 보안 설정 체인형식으로 설정
@Bean
public SecurityFilterChain securityFilterChain (HttpSecurity httpSecurity) throws Exception {
//csrf 무시
httpSecurity.csrf((csrf) -> csrf.disable());
//JWT토큰으로 로그인 하기 때문에 session 비활성화
httpSecurity.sessionManagement((sessionManagement) -> {
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
});
//요청한 경로에 따라 인증처리를 할 것인지, 넘길 것인지, 어떤 역할을 필요로 하는지 정의
httpSecurity.authorizeHttpRequests((request) -> {
request
.requestMatchers("/signup").permitAll()
.requestMatchers("/teacher/regist").hasAuthority(Auth.ADMIN.getAuth())
.requestMatchers("/lecture/regist").hasAuthority(Auth.ADMIN.getAuth())
.requestMatchers("/lecture/{lecture_id}").permitAll()
.requestMatchers("/lecture**").permitAll()
.anyRequest().authenticated();
});
//필터 적용 순서 정의
httpSecurity.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
httpSecurity.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
필터의 경우 두 가지 필터를 사용했다.
JWT인증필터, JWT인가필터
JWT인증 필터
인증 필터는 클라이언트에서 요청을 보내왔을때 토큰이 존재한다면 토큰에 대해 인증을 시도하는 필터이다.
만약 토큰이 유효하다면 그 다음 필터로 넘기고, 유효하지 않다면 인증 실패와 같이 클라이언트에 응답한다.
JWT인가 필터
인가 필터는 앞에 인증 필터에서 토큰이 없을 경우 + 지정한 경로일 경우 오게되는 필터로
클라이언트에서 보낸 아이디, 비밀번호에서 아이디를 UserDetailsService의 구현체를 통해 DB에서 비밀번호를 가져와 클라이언트가 보낸 비밀번호 와 DB에 저장된 비밀번호 를 비교하여 동일시 새로운 토큰을 인가 시킨다.
이때 비교하는 로직은 UsernamePasswordAuthenticationFilter 이 먼저 사용자 정의한 PasswordEncoder 를 통해 비교한다.
JwtAuthroizationFilter
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final MemberDetailServiceCustom memberDetailServiceCustom;
public JwtAuthorizationFilter(JwtUtils jwtUtils, MemberDetailServiceCustom memberDetailServiceCustom) {
this.jwtUtils = jwtUtils;
this.memberDetailServiceCustom = memberDetailServiceCustom;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token)) { //요청에 토큰이 존재하는지
if (jwtUtils.authorizationJwt(token)) {//토큰이 유효한지
Claims memberInfo = jwtUtils.getParsedToken(token);
UserDetails userDetails = memberDetailServiceCustom.loadUserByUsername(memberInfo.getSubject());
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {//토큰이 유효하지 않을 경우
return;
}
}
//토큰이 유효하다면 다음 필터로 넘김
filterChain.doFilter(request, response);
}
}
JwtAuthenticationFilter
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtils jwtUtils;
public JwtAuthenticationFilter(JwtUtils jwtUtils) {
this.jwtUtils = jwtUtils;
}
//UsernamePasswordAuthenticationFilter의 메소드 오버라이딩
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
//Authentication매니저를 통해 비밀번호 검증 후 인증 객체 생성
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getEmail(),
requestDto.getPassword(),
null
)
);
} catch (Exception e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
//인증 성공시 인증객체에 담긴 principal을 가져와 새로운 토큰 생성 및 응답객체에 토큰 추가
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication result) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
MemberDetailCustom authenticationMember = (MemberDetailCustom) result.getPrincipal();
String email = authenticationMember.getEmail();
Long id = authenticationMember.getId();
String auth = authenticationMember.getAuth();
String token = jwtUtils.createJWT(email, id, auth);
jwtUtils.addJwtInHeader(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
Autentication매니저 가 요청한 아이디를 통해 DB에서 비밀번호를 가져올때
UserDetailsService 의 구현체를 사용하여 가져온다.
@Service
public class MemberDetailServiceCustom implements UserDetailsService {
private final MemberRepository memberRepository;
public MemberDetailServiceCustom(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(() ->
new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")
);
return new MemberDetailCustom(member);
}
}
오버라이딩된 loadUserByUsername메소드를 통해 해당 유저의 정보를 DB에서 가져오고
해당 유저의 상태가 잠겨있는지 등 정보를 UserDetails의 구현체에 담는다.
해당 유저의 상태 및 정보를 담은 구현체가 필요하다.
Authentication매니저는 해당 유저의 정보가 담긴 UserDetails 구현체를 통해 비밀번호를 조회하고 여러 상태를 확인하고 인증객체를 생성한다.
@Getter
public class MemberDetailCustom implements UserDetails {
private final Member member;
public MemberDetailCustom(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
String memberAuth = member.getAuth();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(memberAuth);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public Long getId() {
return member.getId();
}
public String getEmail() {
return member.getEmail();
}
public String getAuth() {
return member.getAuth();
}
}