-
Spring Security - 동작 과정, 로그인 구현하기CS/Spring 2024. 7. 4. 01:18
인증 Authentication
사용자가 누구인지 확인하는 단계. 예를들어 서비스를 이용할 수 있는 사용자인지를 확인하는 '로그인'이 인증 단계이다.
인가 Authorization
접근 권한을 확인하는 과정. 예를들어 관리자 페이지에 대해 페이지에 접근한 사용자가 관리자 회원인지 일반 회원인지 확인하는 과정이 인가 단계이다.
일반적으로 인증 단계에서 바릅한 토큰에 사용자 접근 권한 정보를 포함시켜, 토큰을 통해 권한 유무를 확인해 인가를 수행한다.
Spring Security 동작 구조
다양한 서비스에서 인증 또는 인가 등의 보안 기능이 필요하다. spring security는 spring에서 인증, 인가 기능을 제공하는 프로젝트이다.
서블릿에서 서블릿 필터가 인증, 인가 등의 보안 기능을 제공하는데, 스프링은 서블릿 필터를 기반으로 동작한다.

위 그림과 같이 서블릿 컨테이너에 서블릿 필터가 dispatcher servlet 전에 위치해있다.
서블릿 필터가 없다면, 클라이언트의 요청을 dispatcher servlet이 바로 받아서 처리할 것이다.
서블릿 필터를 사용하게되면, 클라이언트의 요청이 dispatcher servlet으로 가기 전에 필터를 거쳐 dispatcher servlet으로 들어올 것이다.
Filter Chain은 말 그대로 여러 필터가 연결된 구조를 말하며 클라이언트 요청이 들어오면 필터 체인에 연결된 순서대로 필터를 거치게 된다.
spring security는 서블릿 필터 체인 외에 추가로 사용할 수 있는 SecurityFilterChain을 제공한다.
사용하고자 하는 spring security의 필터 체인을 서블릿 필터 사이에 동작하게 하기 위해서 DelegatingFilterProxy를 사용한다.
다시 말해, DelegatingFilterProxy를 통해 서블릿 필터 사이에서 SecurityFilterChain이 동작할 수 있게 된다.
SecurityFilterChain에는 많인 필터가 연결돼있는데, 이 중 로그인 인증을 할 때 사용되는 필터가 UsernamePasswordAuthenticationFilter이다.
UsernamePasswordAuthenticationFilter 동작 구조
이름으로 알 수 있듯이 기본적으로 username과 password를 통해 인증을 수행한다.

1. 클라이언트의 요청이 여러 필터를 거치다 UPAFilter로 들어온다.
2. 요청의 내용에서 username과 password를 UsernamePasswordAuthenticationToken객체로 감싸서 AuthenticationManager에 보낸다.
3. AuthenticationManager은 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
4. AuthenticationProvider은 토큰을 UserDetailService에 전달한다.
5. UserDetailService는 전달받은 정보에 담긴 username과 password과 일치하는 엔티티를 DB에서 조회해 반환받는다.
6. 반환받은 엔티티를 UserDetails 객체에 담아 다시 AuthenticationProvider부터 UsernamePasswordAuthenticationFilter까지 연속적으로 전달한다.
스프링 시큐리티는 기본적으로 세션 로그인 방식을 사용한다. 로그인을 인증 과정을 거치면 세션 아이디가 발급돼어 클라이언트의 쿠키에 저장될 것이다. 실제로 확인하면 JSESSIONID라는 이름으로 쿠키값이 저장되어있을 것이다.
💡궁금증! securityContextHolder에 정보가 저장되면 그 정보는 언제 사라질까? 평생 저장되는 것은 아닐텐데?
HTTP 요청 종료:
요청 범위의 SecurityContextHolder 전략을 사용하는 경우, HTTP 요청이 완료되면 SecurityContextHolder에 저장된 인증 정보는 더 이상 유효하지 않게 됩니다. 이는 기본 전략이 MODE_THREADLOCAL인 경우에 해당합니다.각 요청이 끝날 때 SecurityContextPersistenceFilter는 SecurityContextHolder를 비웁니다.로그인 구현하기
1. DB server 준비
2. build.gradle파일에 필요한 라이브러리 추가
//스프링 부트 기본 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' //jpa 사용하기 위한 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.mariadb.jdbc:mariadb-java-client' //롬복을 사용하기 위한 라이브러리 compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30' annotationProcessor('org.projectlombok:lombok') //스프링 시큐리티를 사용하기 위한 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-security'3. yml파일 설정
spring: jwt: secret: 1234dkfjgnkgjdds6789012sdfsd2g4g //아무거나 32자리 이상의 key - 토큰 생성을 위해 필요 datasource: url: ${URL} username: ${USERNAME} password: ${PASSWORD} driver-class-name: org.mariadb.jdbc.Driver jpa: database-platform: org.hibernate.dialect.MariaDBDialect hibernate: ddl-auto: create properties: hibernate: format_sql: true4. 코드 작성
1) 회원 정보를 저장하기 위한 signup 코드
@RestController @RequestMapping("/member") @RequiredArgsConstructor public class MemberController { private final MemberService memberService; @RequestMapping(method = RequestMethod.POST, value = "/signup") public ResponseEntity<String> signup(@RequestBody MemberSignupReq memberSignupReq){ String result = memberService.signup(memberSignupReq); return ResponseEntity.ok(result); } }@Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; public String signup(MemberSignupReq memberSignupReq){ Member member = Member.builder() .name(memberSignupReq.getName()) .email(memberSignupReq.getEmail()) .password(bCryptPasswordEncoder.encode(memberSignupReq.getPassword())) .build(); memberRepository.save(member); return "저장 성공"; } }2) 로그인 요청 모델과 member Entity
//로그인 요청 모델 @Getter public class MemberLoginReq { String email; String password; } //맴버 엔티티 @Entity @Getter @Builder @AllArgsConstructor @NoArgsConstructor public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; private String password; }3) 로그인 필터 커스텀
기본 UsernamePasswordAuthenticationFilter를 사용해도 되지만, 'username'을 기준으로 인증을 수행하기 하기 때문에 'email'로 인증을 수행하도록 하려면 sernamePasswordAuthenticationFilter를 상속받아 커스텀해야한다.
@RequiredArgsConstructor public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; @Override //인증 시도 시 호출되는 메서드 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { MemberLoginReq memberLoginReq; //요청에 담긴 정보를 객체로 매핑 //json 형식으로 들어올 경우 String이기 때문에 바로 email과 password를 받지 못한다 //따라서 json 형식의 정보를 객체에 담아 정보를 얻어야 한다. try { ObjectMapper objectMapper = new ObjectMapper(); ServletInputStream inputStream = request.getInputStream(); String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); memberLoginReq = objectMapper.readValue(messageBody, MemberLoginReq.class); } catch (IOException e) { throw new RuntimeException(e); } //요청 객체에서 email과 password값 추출 String email = memberLoginReq.getEmail(); String password = memberLoginReq.getPassword(); //추출한 정보를 UsernamePasswordAuthenticationToken 객체에 담기 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(email, password, null); //토큰을 authenticationManager에 전달 return authenticationManager.authenticate(usernamePasswordAuthenticationToken); } @Override //인증 성공후 돌아와서 실행되는 메서드 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { //인증 결과를 CustomUserDetails에 저장 CustomUserDetails userDetails = (CustomUserDetails) authResult.getPrincipal(); //인증 결과에 있는 id 추출 Long id = userDetails.getId(); //id 정보와 함께 응답 PrintWriter out = response.getWriter(); out.println("{\"isSuccess\": true, \"id\": id}"); } }4) memberRepository
public interface MemberRepository extends JpaRepository<Member, Long> { Optional<Member> findByEmail(String email); }5) UserDetails 커스텀
나중에 토큰 생성할 때 id 값을 추출해야하는데, 기본 UserDetails는 getId메서드가 없기 때문에 커스텀 필요
@RequiredArgsConstructor public class CustomUserDetails implements UserDetails { private final Member member; //인터페이스 구현체 - 필수 구현 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return member.getPassword(); } @Override public String getUsername() { return member.getEmail(); } //id를 응답 정보에 담기 위해 추가 구현 public Long getId(){ return member.getId(); } }6) UserDetailService 커스텀
기본 UserDetailsService에는 findByUsername이 구현돼있기 때문에 커스텀 해줘야 함
@Service @RequiredArgsConstructor public class UserDetailService implements UserDetailsService { private final MemberRepository memberRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { //DB에서 들어온 요청의 email과 일치하는 엔티티 찾아 반환받기 Member member = memberRepository.findByEmail(email).get(); //CustomUserDetails에 담아 반환 CustomUserDetails userDetails = new CustomUserDetails(member); return userDetails; } }7) 필터 체인 설정 클래스
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig{ private final AuthenticationConfiguration authenticationConfiguration; private final JwtUtil jwtUtil; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((auth)-> auth.disable()); http.httpBasic((auth)->auth.disable()); http.authorizeHttpRequests((auth)-> auth .anyRequest().permitAll() ); //login filter 만들어서 //userpasswordtfilter에 대치시키기 LoginFilter loginFilter = new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil); http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean //cors error 방지 public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:3000"); // 허용할 출처 config.addAllowedOrigin("http://localhost:8080"); // 허용할 출처 config.addAllowedMethod("*"); // 허용할 메서드 (GET, POST, PUT 등) config.addAllowedHeader("*"); // 허용할 헤더 config.setAllowCredentials(true); // 자격 증명 허용 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } @Bean BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }5. 코드 실행 후 요청 날리기
(signup 후 login 요청)

'CS > Spring' 카테고리의 다른 글
Kafka(1) - 카프카란 무엇일까? (0) 2024.07.26 JPA - N+1 문제 해결하기, 성능개선(페이징 처리) (0) 2024.07.22 📬JPA, Spring Mail 사용해 이메일 인증 기능 구현하기 (+SMTP,IMAP) (0) 2024.06.29 Spring - 스프링 부트의 동작 방식 (2) 2024.06.27 JPA - save 메서드에 대하여🤯 (엔티티 생명주기, isNew(), @GeneratedValue, persist(),merge()) (0) 2024.06.26