이 기사에서는 Spring 보안을 살펴보고 OAuth 2.0을 사용하여 인증 시스템을 구축할 것입니다.
Spring Security의 작동 방식을 자세히 알아보기 전에 Java 기반 웹 서버의 요청 처리 수명 주기를 이해하는 것이 중요합니다. Spring Security는 이 라이프사이클에 완벽하게 통합되어 들어오는 요청을 보호합니다.
Spring Security를 사용하는 Spring 기반 애플리케이션에서 HTTP 요청을 처리하는 라이프사이클에는 여러 단계가 포함되며 각 단계는 요청을 처리, 검증 및 보호하는 데 중요한 역할을 합니다.
클라이언트(예: 브라우저, 모바일 앱 또는 Postman과 같은 API 도구)가 서버에 HTTP 요청을 보낼 때 수명 주기가 시작됩니다.
예:
GET /api/admin/dashboard HTTP/1.1
서블릿 컨테이너(예: Tomcat)는 요청을 수신하고 이를 Spring 애플리케이션의 전면 컨트롤러인 DispatcherServlet에 위임합니다. 여기가 애플리케이션의 처리 파이프라인이 시작되는 곳입니다.
DispatcherServlet이 요청을 처리하기 전에 Spring Security의 필터 체인이 요청을 가로챕니다. 필터 체인은 일련의 필터로, 각 필터는 특정 보안 작업을 처리합니다. 이러한 필터는 요청이 애플리케이션 로직에 도달하기 전에 인증 및 권한 부여 요구 사항을 충족하는지 확인합니다.
인증 필터:
이 필터는 요청에 사용자 이름/비밀번호, JWT 또는 세션 쿠키와 같은 유효한 자격 증명이 포함되어 있는지 확인합니다.
인증 필터:
인증 후 이러한 필터는 인증된 사용자가 요청된 리소스에 액세스하는 데 필요한 역할 또는 권한을 가지고 있는지 확인합니다.
기타 필터:
* **CsrfFilter**: Validates CSRF tokens to prevent Cross-Site Request Forgery attacks. * **CorsFilter**: Manages Cross-Origin Resource Sharing (CORS) rules for secure API access from different domains. * **ExceptionTranslationFilter**: Handles security-related exceptions (e.g., invalid credentials) and sends appropriate responses to the client.
인증이 성공하면 Spring Security는 인증 객체를 생성하여 SecurityContext에 저장합니다. 스레드 로컬 저장소에 저장되는 경우가 많은 이 객체는 요청 수명 주기 전반에 걸쳐 액세스할 수 있습니다.
주체: 인증된 사용자(예: 사용자 이름)를 나타냅니다.
자격 증명: JWT 토큰 또는 비밀번호와 같은 인증 세부정보를 포함합니다.
권한: 사용자에게 할당된 역할과 권한이 포함됩니다.
요청은 인증 필터를 통과합니다.
자격 증명이 유효하면 인증 개체가 생성되어 SecurityContext에 추가됩니다.
자격 증명이 유효하지 않은 경우 ExceptionTranslationFilter는 클라이언트에 401 Unauthorized 응답을 보냅니다.
요청이 Spring 보안 필터 체인을 성공적으로 통과하면 DispatcherServlet이 다음 작업을 대신합니다.
처리기 매핑:
URL 및 HTTP 메소드를 기반으로 들어오는 요청을 적절한 컨트롤러 메소드에 매핑합니다.
컨트롤러 호출:
매핑된 컨트롤러는 요청을 처리하고 서비스 및 저장소와 같은 다른 Spring 구성 요소의 도움을 받아 적절한 응답을 반환합니다.
Spring Security는 필터를 통해 이 라이프사이클에 통합되어 가장 초기 단계에서 요청을 차단합니다. 요청이 애플리케이션 로직에 도달하면 이미 인증 및 권한이 부여되어 핵심 애플리케이션에서 합법적인 트래픽만 처리되도록 보장합니다.
Spring Security의 설계는 인증, 권한 부여 및 기타 보안 조치가 선언적으로 처리되도록 보장하여 개발자가 필요에 따라 동작을 사용자 정의하거나 확장할 수 있는 유연성을 제공합니다. 이는 모범 사례를 적용할 뿐만 아니라 최신 애플리케이션의 복잡한 보안 요구 사항 구현을 단순화합니다.
Spring Security의 필터 체인을 살펴본 후 인증 및 승인 프로세스에서 중추적인 역할을 하는 다른 주요 구성 요소를 살펴보겠습니다.
AuthenticationManager는 사용자의 자격 증명을 확인하고 유효한지 확인하는 데 사용되는 단일 메소드인 authenticate(인증 인증)를 정의하는 인터페이스입니다. AuthenticationManager는 여러 공급자를 등록할 수 있는 코디네이터로 생각할 수 있으며, 요청 유형에 따라 올바른 공급자에게 인증 요청을 전달합니다.
AuthenticationProvider는 자격 증명을 기반으로 사용자를 인증하기 위한 계약을 정의하는 인터페이스입니다. 사용자 이름/비밀번호, OAuth 또는 LDAP와 같은 특정 인증 메커니즘을 나타냅니다. 여러 AuthenticationProvider 구현이 공존할 수 있으므로 애플리케이션이 다양한 인증 전략을 지원할 수 있습니다.
인증 개체:
AuthenticationProvider는 사용자의 자격 증명(예: 사용자 이름 및 비밀번호)을 캡슐화하는 인증 개체를 처리합니다.
인증 방법:
각 AuthenticationProvider는 실제 인증 논리가 있는 authenticate(인증 인증) 메소드를 구현합니다. 이 방법은:
* **CsrfFilter**: Validates CSRF tokens to prevent Cross-Site Request Forgery attacks. * **CorsFilter**: Manages Cross-Origin Resource Sharing (CORS) rules for secure API access from different domains. * **ExceptionTranslationFilter**: Handles security-related exceptions (e.g., invalid credentials) and sends appropriate responses to the client.
데이터베이스 기반 AuthenticationProvider는 사용자 이름과 비밀번호의 유효성을 검사합니다.
OAuth 기반 인증 공급자는 외부 ID 공급자가 발급한 토큰의 유효성을 검사합니다.
UserDetailsService는 Spring 문서에서 사용자별 데이터를 로드하는 핵심 인터페이스로 설명됩니다. 여기에는 사용자 이름을 매개변수로 받아들이고 ==User== ID 개체를 반환하는 단일 메서드 loadUserByUsername이 포함되어 있습니다. 기본적으로 우리는 loadUserByUsername 메소드를 재정의하는 UserDetailsService 클래스를 생성하고 구현합니다.
* Validates the user’s credentials. * Returns an authenticated `Authentication` object upon success. * Throws an `AuthenticationException` if authentication fails.
이제 세 가지가 함께 작동하는 방식은 AuthenticationManager가 지정된 공급자 유형에 따라 인증을 수행하도록 AuthenticationProvider에 요청하고 UserDetailsService 구현이 AuthenticationProvider가 사용자 세부 정보를 증명하는 데 도움이 된다는 것입니다.
사용자는 자격 증명(사용자 이름 및 비밀번호) 또는 JWT 토큰(헤더에 있음)을 사용하여 인증된 엔드포인트에 요청을 보내고 해당 요청은 인증 필터로 전달됩니다.
인증 필터(예: UsernamePasswordAuthenticationFilter):
이 사용자 정의 필터는 OncePerRequestFilter를 확장하고 UsernamePasswordAuthenticationFilter 앞에 배치되며, 요청에서 토큰을 추출하고 유효성을 검사하는 역할을 합니다.
토큰이 유효하면 UsernamePasswordAuthenticationToken을 생성하고 해당 토큰을 보안 컨텍스트에 설정하여 요청이 인증되었음을 Spring 보안에 알리고 이 요청이 UsernamePasswordAuthenticationFilter로 전달되면 UsernamePasswordAuthenticationToken이 있으므로 전달됩니다.
* **CsrfFilter**: Validates CSRF tokens to prevent Cross-Site Request Forgery attacks. * **CorsFilter**: Manages Cross-Origin Resource Sharing (CORS) rules for secure API access from different domains. * **ExceptionTranslationFilter**: Handles security-related exceptions (e.g., invalid credentials) and sends appropriate responses to the client.
이 UsernamePasswordAuthenticationToken은 UserDetails 클래스의 도움으로 사용자 이름과 비밀번호를 인증한 후 토큰 대신 사용자 이름과 비밀번호를 전달한 경우 AuthenticationManager 및 AuthenticationProvider의 도움으로 생성됩니다.
* Validates the user’s credentials. * Returns an authenticated `Authentication` object upon success. * Throws an `AuthenticationException` if authentication fails.
UserDetailsService: AuthenticationProvider는 UserDetailsService를 사용하여 사용자 이름을 기반으로 사용자 세부 정보를 로드합니다. 그리고 이를 UserDetailsService 구현으로 제공합니다
자격 증명: 제공된 비밀번호를 사용자 세부 정보에 저장된 비밀번호와 비교합니다(일반적으로 PasswordEncoder 사용).
package com.oauth.backend.services; import com.oauth.backend.entities.User; import com.oauth.backend.repositories.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; @Component public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if(user==null){ throw new UsernameNotFoundException(username); } return new UserDetailsImpl(user); } public UserDetails loadUserByEmail(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email); if(user==null){ throw new UsernameNotFoundException(email); } return new UserDetailsImpl(user); } }
이제 Spring 보안이 무엇을 해야 할지 알 수 있도록 이러한 모든 다양한 필터와 Bean을 구성해야 하므로 모든 구성을 지정하는 구성 클래스를 생성합니다.
@Component public class JWTFilter extends OncePerRequestFilter { private final JWTService jwtService; private final UserDetailsService userDetailsService; public JWTFilter(JWTService jwtService,UserDetailsService userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); if(authHeader == null || !authHeader.startsWith("Bearer")) { filterChain.doFilter(request,response); return; } final String jwt = authHeader.substring(7); final String userName = jwtService.extractUserName(jwt); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if(userName !=null && authentication == null) { //Authenticate UserDetails userDetails = userDetailsService.loadUserByUsername(userName); if(jwtService.isTokenValid(jwt,userDetails)) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); SecurityContextHolder.getContext() .setAuthentication(authenticationToken); } } filterChain.doFilter(request,response); } }
지금까지 Spring Security의 도움으로 인증을 이해하고 구성했습니다. 이제 테스트할 차례입니다.
AuthController(로그인 및 등록 처리)와 ProductController(더미 보호 컨트롤러) 두 개의 컨트롤러가 있는 간단한 앱을 만들어 보겠습니다.
* **CsrfFilter**: Validates CSRF tokens to prevent Cross-Site Request Forgery attacks. * **CorsFilter**: Manages Cross-Origin Resource Sharing (CORS) rules for secure API access from different domains. * **ExceptionTranslationFilter**: Handles security-related exceptions (e.g., invalid credentials) and sends appropriate responses to the client.
* Validates the user’s credentials. * Returns an authenticated `Authentication` object upon success. * Throws an `AuthenticationException` if authentication fails.
package com.oauth.backend.services; import com.oauth.backend.entities.User; import com.oauth.backend.repositories.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; @Component public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if(user==null){ throw new UsernameNotFoundException(username); } return new UserDetailsImpl(user); } public UserDetails loadUserByEmail(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email); if(user==null){ throw new UsernameNotFoundException(email); } return new UserDetailsImpl(user); } }
@Component public class JWTFilter extends OncePerRequestFilter { private final JWTService jwtService; private final UserDetailsService userDetailsService; public JWTFilter(JWTService jwtService,UserDetailsService userDetailsService) { this.jwtService = jwtService; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { final String authHeader = request.getHeader("Authorization"); if(authHeader == null || !authHeader.startsWith("Bearer")) { filterChain.doFilter(request,response); return; } final String jwt = authHeader.substring(7); final String userName = jwtService.extractUserName(jwt); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if(userName !=null && authentication == null) { //Authenticate UserDetails userDetails = userDetailsService.loadUserByUsername(userName); if(jwtService.isTokenValid(jwt,userDetails)) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); SecurityContextHolder.getContext() .setAuthentication(authenticationToken); } } filterChain.doFilter(request,response); } }
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception{ return config.getAuthenticationManager(); }
@Bean public AuthenticationProvider authenticationProvider(){ DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(userDetailsServiceImpl); authenticationProvider.setPasswordEncoder(passwordEncoder); return authenticationProvider; }
지금까지 우리는 등록, 로그인 및 확인을 구현했지만 Google/Github으로 로그인 기능도 추가하고 싶다면 OAuth2.0의 도움으로 이를 수행할 수 있습니다.
OAuth 2.0은 사용자가 해당 플랫폼의 자격 증명을 공유하지 않고도 다른 플랫폼(예: Google Drive, Github)에 저장된 리소스에 대한 액세스 권한을 제3자 애플리케이션에 부여할 수 있도록 하는 승인을 위해 만들어진 프로토콜입니다.
주로 'Google로 로그인', 'github로 로그인'과 같은 소셜 로그인을 활성화하는 데 사용됩니다.
Google, Facebook, Github와 같은 플랫폼은 소셜 로그인 또는 액세스 승인을 위해 OAuth 2.0 프로토콜을 구현하는 승인 서버를 제공합니다.
이제 각 컨셉을 하나씩 살펴보겠습니다
리소스 소유자는 타사 애플리케이션(귀하의 애플리케이션)을 승인하려는 사용자입니다.
리소스 서버의 데이터나 리소스에 액세스하려는 (타사) 애플리케이션입니다.
사용자 데이터가 저장되는 서버이며 타사 애플리케이션에서 액세스할 수 있습니다.
리소스 소유자를 인증하고 클라이언트(예: Google 계정)에 액세스 토큰을 발급하는 서버입니다.
인증 서버가 클라이언트에 발급한 자격 증명으로, 사용자를 대신하여 리소스 서버에 액세스할 수 있습니다. 일반적으로 수명이 짧고 곧 만료되므로 사용자가 다시 인증할 필요가 없도록 이 액세스 토큰을 새로 고치기 위해 새로 고침 토큰도 제공됩니다.
사용자가 부여한 특정 권한으로, 클라이언트가 사용자 데이터로 수행할 수 있는 작업과 수행할 수 없는 작업을 정의합니다. 예를 들어 승인을 위해서는 프로필, 이름 등과 같은 사용자 정보만 필요하지만 파일 액세스에는 다른 범위가 필요합니다.
클라이언트 애플리케이션이 Authorization Server로부터 액세스 토큰을 얻을 수 있는 방법을 말합니다. 권한 부여는 클라이언트 애플리케이션이 리소스 소유자의 보호되는 데이터에 액세스할 수 있는 권한을 부여하는 프로세스 및 조건을 정의합니다.
클라이언트 비밀번호 및 기타 자격 증명을 브라우저에 노출할 필요가 없으므로 안전합니다
OAuth 2.0에서 제공하는 Grant 종류는 주로 2가지가 있습니다
가장 많이 사용되는 승인/메소드 유형이며 가장 안전하며 서버 측 애플리케이션에 사용됩니다
여기서 클라이언트는 인증 코드를 백엔드에 제공하고 백엔드는 클라이언트에 액세스 토큰을 제공합니다.
과정:
단일 페이지 앱(SPA) 또는 백엔드가 없는 애플리케이션에서 사용됩니다. 여기서 액세스 토큰은 브라우저 자체에서 직접 생성 및 발급됩니다.
과정:
완전한 이해를 위해 두 가지를 별도로 구현하겠지만 먼저 필요한 인증 코드 부여를 구현하겠습니다
인증 서버
플랫폼(예: google, github) 중 하나일 수도 있고 KeyCloak을 사용하여 직접 만들 수도 있고 OAuth 2.0 표준을 준수하여 직접 구축할 수도 있습니다(다음 블로그에서 이에 대해 설명하겠습니다).
스프링 부트 애플리케이션
이것은 코드 교환, 확인, 사용자 세부 정보 저장 및 JWT 토큰 할당과 같은 모든 작업을 처리하는 주요 백엔드 애플리케이션/서비스가 될 것입니다
React 애플리케이션(프런트엔드)
이것은 인증을 위해 사용자를 인증 서버로 리디렉션하는 클라이언트가 됩니다.
그래서 구현에서 우리가 할 일은 프런트엔드(웹/앱)가 사용자를 Google 로그인으로 리디렉션하는 것입니다. 리디렉션 URI는 백엔드 엔드포인트로 리디렉션되어 더 많은 제어권을 갖게 됩니다. 나중에 이에 대해 리디렉션_url과 함께 설명하겠습니다. 또한 앱의 클라이언트 ID를 전달하며 이러한 모든 정보는 쿼리 매개변수로 전송됩니다.
아니요, 사용자가 Google에 성공적으로 로그인하면 인증 서버(Google)가 요청을 백엔드 지점으로 리디렉션하고 거기서 우리가 할 일은 인증 코드를 인증 서버와 교환하여 액세스 토큰과 새로 고침 토큰을 얻은 다음 우리는 원하는 대로 인증을 처리할 수 있으며 마지막으로 쿠키를 포함하고 대시보드로 리디렉션되거나 보호된 페이지가 될 수 있는 응답을 프런트엔드로 다시 보냅니다.
이제 코드를 살펴보겠습니다. 단, OAuth 클라이언트용 Google 콘솔 대시보드의 승인된 리디렉션 URL에 백엔드 엔드포인트의 URL을 추가했는지 확인하세요.
* **CsrfFilter**: Validates CSRF tokens to prevent Cross-Site Request Forgery attacks. * **CorsFilter**: Manages Cross-Origin Resource Sharing (CORS) rules for secure API access from different domains. * **ExceptionTranslationFilter**: Handles security-related exceptions (e.g., invalid credentials) and sends appropriate responses to the client.
이제 괜찮습니다. 테스트를 위해 로그인 및 등록 기능과 컨텍스트만 아는 간단한 프런트엔드 애플리케이션을 만들 수 있습니다.
지금까지 읽어주셔서 감사드리며, 제안사항이 있으시면 댓글로 남겨주세요
위 내용은 스프링 보안과 OAuth 이해의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!