728x90

인증 처리 흐름

인증 방식은 여러 종류가 있지만 우선은 UsernamePasswordAuthenticationFilter에 대해 살펴보겠다.

1. 사용자의 로그인 요청

사용자가 로그인을 위해

Credential(로그인 정보)를 서버에 전달한다.

 

2. Authentication 객체 생성 및 전달

UsernamePasswordAuthenticationToken authRequest = 
		UsernamePasswordAuthenticationToken.unauthenticated(username, password);

UsernamePasswordAuthenticationFilter는 사용자 요청을 전달 받아

Authentication 인터페이스의 구현체인 AuthenticationToken을 생성하여

해당 객체를 AuthenticationManager에 전달한다.

 

현재까지의 Authentication은 인증이 된 상태가 아니다.

 

3. AuthenticationManager

AuthenticationManager는 말그래도 인증 관리자 역할을 하는 인터페이스고

이를 구현한 ProviderManager를 통해 전달 받은 Authentication을 사용해

사용자 인증 작업을 관리한다.

 

실제로 인증을 처리하는 역할이 아니라 단계별로 적절한 컴포넌트를 사용해

인증 처리 작업을 시키는 역할이다.

 

4. AuthenticationProvider

AuthenticationProvider는 AuthenticationManager로부터

Authentication을 전달 받아 UserDetailsService를 사용해 UserDetails를 조회한다.

 

UserDetails는 데이터베이스에서 조회한 사용자 인증에 사용할 정보와

사용자의 권한 정보를 포함하고 있는 컴포넌트다.

 

5. 인증된 Authentication 객체 생성 및 전달

AuthenticationProvider는 PasswordEncoder를 이용해

UserDetails와 Authentication의 패스워드를 검증하여

검증에 성공하면 인증된 Authentication 객체를 생성하고

실패한다면 예외를 발생시킨다.

 

검증에 성공하여 생성된 Authentication 객체를 AuthenticationManager에 전달하고

AuthenticationManager 이를 다시 UsernamePasswordAuthenticationFilter에 전달한다.

 

6. 인증된 Authentication 객체 저장

UsernamePasswordAuthenticationFilter는 전달 받은 인증된 Authentication 객체를

SecurityContextHolder를 사용해 SecurityContext에 저장한다.

 

SecurityContext는 이후에 세션 정책에 따라서

HttpSession 혹은 JWT 등의 방식으로 사용자의 인증 상태에 활용된다.

 

인증 처리 흐름 요약

  1. 사용자가 서버에 Credential과 함께 로그인 요청
  2. 인증되지 않은 Authentication  객체 생성
  3. AuthenticationManager가 Authentication  객체를 사용해 인증 작업 관리
  4. AuthenticationProvider가 UserDetailsService를 통해 데이터베이스에서 사용자 인증 정보 조회
  5. PasswordEncoder로 사용자 인증 정보와 Credential의 패스워드 일치 검증
  6. 일치한다면 인증된 Authentication  객체 생성
  7. 인증된 Authentication  객체를 UsernamePasswordAuthenticationFilter로 전달
  8. 인증된 Authentication  객체를 SecurityContextHolder를 사용해 SecurityContext에 저장

 

권한 부여(인가) 처리 흐름

1. AuthorizationFilter

AuthorizationFilter는 SecurityContextHolder에서 Authentication을 가져온 후에

Authentication과 HttpServletRequest를 AuthorizationManager에게 전달한다.

2. RequestMatcherDelegatingAuthorizationManager

AuthorizationManager 인터페이스의 구현체 중 RequestMatcherDelegatingAuthorizationManager가

RequestMatcher 평가식에 매치되는 AuthorizationManager에게 인가 처리를 위임

3. AuthorizationManager

RequestMatcher를 통해 매치되는 AuthorizationManager 구현 클래스가 인가 처리를 수행

 

적절한 권한이라면 사용자의 요청을 계속 처리하고 적절하지 않다면 AccessDeniedException 발생

 

권한 부여 컴포넌트

AuthorizationFilter

권한 부여 필터

URL을 통해 사용자의 액세스를 제한

스프링 시큐리티 5.5 버전 이후부터 FilterSecurityInterceptor를 대체함

 

AuthorizationManager를 주입 받아 적절한 권한 부여 여부를 체크하는데

이때 권한 체크 로직은 URL 기반으로 권한 부여 처리를 하기 위해

RequestMatcherDelegatingAuthorizationManager 구현 클래스를 사용한다.

 

AuthorizationManager

권한 부여 처리를 총괄하는 역할을 담당하는 인터페이스

권한 부여 여부를 체크하는 check 메서드만 정의되어 있음

 

RequestMatcherDelegatingAuthorizationManager

AuthorizationManager의 구현 클래스 중 하나

직접 권한 부여 처리를 수행하지 않음

RequestMatcher를 통해 매치되는 AuthorizationManager 구현 클래스에 처리를 위임

.antMatchers("/orders/**").hasRole("ADMIN")

RequestMatcher 같은 경우는 위의 코드처럼

메서드 체인 정보를 말한다.

728x90

DelegatingPasswordEncoder의 장점

DelegatingPasswordEncoder는 비밀번호를 암호화 할 때 사용했던

PasswordEncoder 구현 객체를 생성해주는 컴포넌트다.

 

사용할 암호화 알고리즘을 지정하지 않으면 스프링 시큐리티가 권장하는

최신 암호화 알고리즘을 적용하여 패스워드를 암호화 해준다.

 

암호화 방식을 변경할 일이 생겨도 언제든 암호화 방식을 바꿀 수 있다.

(기존 암호화 된 패스워드에 대한 마이그레이션 필요)

 

커스텀 DelegatingPasswordEncoder 생성

PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

기존에는PasswordEncoderFactories의 createDelegatingPasswordEncoder 메서드를 사용하여

DelegatingPasswordEncoder 객체를 생성하여 다시 적절한 PasswordEncoder 객체를 생성했다.

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);

하지만 위와 같이 DelegatingPasswordEncoder의 생성자로 키값과 맵 객체를 넘겨주면

해당 맵 객체에서 키에 해당하는 암호화 알고리즘을 적용할 수 있다.

 

스프링 시큐리티의 암호화 형식

데이터베이스에서 암호화 된 패스워드를 살펴보면

{암호화알고리즘}암호화된패스워드
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

위와 같은 PasswordEncoder의 ID와 encodedPassword

형식으로 저장된 것을 볼 수 있다.

 

암호화 알고리즘

Plain Text

암호화하지 않은 텍스트 그 자체

반드시 암호화 하는 것이 좋다.

 

Hash

단방향 암호화

패스워드 같은 경우는 다시 복호화 할 필요가 없기 때문에

해시 알고리즘을 사용하는 것도 괜찮다.

 

MD5

단방향 암호화

MD2, MD4 해시 알고리즘의 결함을 보완한 알고리즘

 

다이제스트: 원본 메시지를 암호화한 메시지

 

SHA

MD5의 결함을 보완한 해시 알고리즘

해시된 문자열의 비트 값을 회전하면서 해시 처리를 함

 

Salt

알고리즘보다는 대응책에 가깝다.

원본 메시지에 임의의 문자열을 추가하여 해시 처리 함

 

PBKDF2, bcrypt, scrypt

솔트와 키 스트레칭, 메모리 오버헤드 등에

복잡한 알고리즘까지 더하여

복호화를 어렵게 하는 해시 알고리즘

728x90

사용자의 요청이 왔을 때 스프링 시큐리티가 어떤 일들을 수행하는지

로그인 과정을 통해 알아보겠다.

 

인증과 인가

우선은 인증과 인가의 개념과 차이에 대해서 알아야 한다.

 

이름은 비슷하지만 둘의 역할은 조금 다른데

인증은 말그대로 만약 사용자라는 객체가 있다면

해당 객체가 유효한지 인증해주는 것이다.

 

인가는 인증된 객체가 어디까지 접근할 수 있는지

객체의 권한을 확인한다.

 

로그인을 통해 사이트의 인증을 받았다면

로그인한 사용자의 권한에 따라서

이용할 수 있는 기능을 제한하는 것이 인가라고 볼 수 있다.

사용자 시점

사용자가 정확한 인증 정보를 서버에 제공 하였다면

아래와 같은 순서대로 요청이 처리된다.

 

  1. 정확한 로그인 정보(Credential) 제공
  2. 로그인 성공
  3. 접근 권한이 있는 요청 전달
  4. 응답 받은 정보 조회

스프링 시큐리티 시점

  1. 사용자가 서버에 리소스를 요청
  2. 스프링 시큐리티 인증 관리자가 Credential 요청
  3. 사용자가 Credential 제공
  4. 인증 관리자가 데이터베이스에서 사용자 인증 정보 조회
  5. 유효한 Credential인지 확인
  6. 유효하지 않다면 예외 발생, 유효하다면 인증 처리
  7. 접근 결정 관리자가 인증 처리 정보를 토대로 사용자가 요청한 리소스에 접근할 충분한 권한이 있는지 확인
  8. 권한이 부족하다면 예외 발생, 충분하다면 리소스를 응답

복잡해보이지만 간단하게 말하자면

사용자로부터 인증 정보를 받은 후에 데이터베이스의 인증 정보와 일치하는지 확인 후에

인증에 성공하면 해당 사용자가 리소스에 접근할 권한이 있는지 확인하고

최종적으로 리소스 응답 여부를 결정하는 것이다.

 

즉, 리소스 요청 ▶ 인증 ▶ 인가 ▶ 리소스 전달 순서다.

필터의 처리 과정

클라이언트 측에서 요청을 하면 아래와 같이 서블릿 필터 단계에서 검증하는 과정을 거친다.

  1. 클라이언트의 요청
  2. 서블릿 필터 체인에서 DelegatingFilterProxy를 통해 스프링 시큐리티 필터와 연결
  3. FilterChainProxy에서 스프링 시큐리티 필터 체인을 호출
  4. 2 - 3번 과정을 반복하며 순서대로 모든 필터 체인들을 통해 검증
  5. 검증을 통과했다면 사용자의 요청을 처리하는 과정을 수행

익숙하지 않은 용어들이 많이 나와서 어렵게 보이겠지만 알고나면 간단하다.

 

우선 서블릿 필터 체인과 스프링 시큐리티 필터 체인은

서로 다른 영역(서블릿 컨테이너와 스프링 컨테이너)에 존재하기 때문에

DelegatingFilterProxy를 통해 두 영역을 이어준다.

 

두 영역을 이어주는 이유는

서블릿 필터 단계에서 검증을 해야하기 때문이다.

 

두 영역이 연결되었기에 FilterChainProxy를 통해 서블릿 필터 체인 영역에서

스프링 시큐리티 필터 체인을 호출하여 사용한다.

 

이 과정들을 반복하여 모든 필터를 거쳐 검증이 끝나고 난 후에야

요청이 처리되기 시작한다.

필터

필터는 일상생활에서 공기청정기의 필터처럼

무언가를 걸러내주는 역할을 담당한다.

  • Servlet Container
    • Servlet Filter
      • DelegatingFilterProxy
      • FilterChainProxy
        • Spring Security Filter Chain
    • DispatcherServlet
    • Intercepter
    • Controller, Service, Component 등

서블릿 필터는 위와 같이 실질적인 요청에 대한 처리가 시작되기 전에

검증을 하기 위해 서블릿 컨테이너의 시작점에 존재한다.

필터 체인

  • 체인이라는 말처럼 필터들이 사슬로 연결된 필터의 묶음을 말한다.
  • 서블릿의 필터 체인은 요청 URI를 기반으로 어떤 필터와 서블릿을 매핑할지 결정한다.
  • 필터 체인 안에서 필터의 순서를 지정할 수 있다.
    • @Order 어노테이션이나 Ordered 인터페이스를 구현해 순서를 지정할 수 있다.
    • FilterRegistrationBean을 이용해 필터의 순서를 명시적으로 지정할 수 있다.

커스텀 필터 만들기

public class MyCustomFilter implements Filter {
    // 필터 초기화
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
        System.out.println("필터 생성");
    }

	@Override
	public void doFilter(ServletRequest request,
                        ServletResponse response,
                        FilterChain chain)
                        throws IOException, ServletException {
        // 다음 Filter 전의 전처리 작업	
		System.out.println("필터 시작");
        
        chain.doFilter(request, response); // 필터가 처리하는 실제 로직

        // 후처리 작업
        System.out.println("필터 종료");
    }
	
    // 필터가 사용한 자원 반납
    public void destroy() {
    	System.out.println("필터 삭제");
        Filter.super.destroy();
    }
}

우선 Filter 인터페이스를 구현할 MyCustomFilter 클래스를 생성한다.

 

init 메서드를 통해 필터를 초기화 한 후에

doFilter 메서드를 통해 전처리 및 후처리, 실질적인 필터의 처리 로직을 구현한다.

 

이때 전처리는 ServletRequest 객체를 사용하고

후처리는 ServletResponse 객체를 사용한다.

 

destroy 메서드를 사용하여 사용한 자원을 반납하여 필터를 끝낸다.

@Configuration
public class FilterConfiguration {

    @Bean
    public FilterRegistrationBean<MyCustomFilter> customFilterRegister()  {
        FilterRegistrationBean<MyCustomFilter> registrationBean = new FilterRegistrationBean<>(new MyCustomFilter());
        return registrationBean;
    }
}

커스텀 필터를 적용하기 위해 FilterConfiguration 클래스를 만든 후에

FilterRegistrationBean의 생성자로 필터 인터페이스를 구현한 MyCustomFilter를 넘겨주어

빈으로 등록하여 서블릿 필터 체인에서 사용할 수 있도록 한다.

@Bean
public FilterRegistrationBean<MyCustomFilter> customFilterARegister()  {
    FilterRegistrationBean<MyCustomFilterA> registrationBean = new FilterRegistrationBean<>(new MyCustomFilterA());
    registrationBean.setOrder(1);
    return registrationBean;
}

@Bean
public FilterRegistrationBean<MyCustomFilter> customFilterBRegister()  {
    FilterRegistrationBean<MyCustomFilterB> registrationBean = new FilterRegistrationBean<>(new MyCustomFilterB());
    registrationBean.setOrder(2);
    return registrationBean;
}

만약 여러 개의 필터를 사용하는 경우에는 위와 같이 우선순위를 지정할 수도 있는데

지정한 숫자가 낮을 수록 우선순위가 높은 필터다.

728x90

AuthenticationProvider 커스텀하기

@Component
public class CustomUserAuthenticationProvider implements AuthenticationProvider {   // (1)
    private final CustomUserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public HelloUserAuthenticationProvider(CustomUserDetailsService userDetailsService, 
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }
    
    // ...생략
}

우선 AuthenticationProvider 인터페이스를 구현할 CustomUserAuthenticationProvider 클래스를 생성한다.

 

인증을 처리하기 위해 인증 정보를 조회하고 패스워드를 복호화해줄 때 사용할

기존에 만들어두었던 CustomUserDetailsService와 PasswordeEncoder 객체를 주입받는다.

@Override
public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.equals(authentication);
}

private void verifyCredentials(Object credentials, String password) {
    if (!passwordEncoder.matches((String)credentials, password)) {
        throw new BadCredentialsException("Invalid User name or User Password");
    }
}

우선 supports 메서드를 오버라이딩 하여

CustomUserAuthenticationProvider가 UsernamePasswordAuthentication 방식의

인증을 지원할 것이라고 스프링 시큐리티에 알려준다.

(해당 값이 트루면 해당 클래스의 authenticate 메서드 호출)

 

verifyCredentials 메서드는 이후에 사용할

데이터베이스의 패스워드와 크레덴셜의 패스워드를 비교하는 메서드다.

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

    String username = authToken.getName();
    Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));

    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

    String password = userDetails.getPassword();
    verifyCredentials(authToken.getCredentials(), password);

    Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

    return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
}

authenticate 메서드는 커스텀한 인증 처리 로직에 따라 사용자 인증 여부를 결정하여

해당 메서드를 오버라이딩 하면 커스텀 인증 처리 로직을 적용할 수 있다.

UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

String username = authToken.getName();
Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));

파라미터로 받아온 authentication을 캐스팅하여 UsernamePasswordAuthenticationToken 객체를 얻어서

해당 객체에서 사용자의 Username을 얻어온다.

UserDetails userDetails = userDetailsService.loadUserByUsername(username);

String password = userDetails.getPassword();
verifyCredentials(authToken.getCredentials(), password);

userDetailsService에서 Username과 일치하는 사용자 인증 정보를 가져온 후에

가져온 정보에서 패스워드를 가져와 로그인 정보의 패스워드와 비교한다.

Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();

return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);

비밀번호까지 일치한다면 인증을 통과한 것이기에

해당 사용자의 권한을 생성한 후에 인증된 사용자의 정보를 리턴한다.

스프링 시큐리티의 예외 처리

try {
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    String password = userDetails.getPassword();
    verifyCredentials(authToken.getCredentials(), password);

    Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
    return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
} catch (Exception ex) {
    throw new UsernameNotFoundException(ex.getMessage());

스프링 시큐리티는 인증 실패 시 AuthenticationException 예외가 발생하지 않으면

예외에 대한 별도의 처리를 하지 않고 서블릿 컨테이너 측으로 넘기기 때문에

위의 코드처럼 기존의 인증 처리 로직을 트라이-캐치 문으로 감싼 후에

발생하는 모든 예외를 잡아서 AuthenticationException을 리턴하거나 상속 받는 예외를 리턴한다.

 

즉, 커스텀 인증을 구현할 때 발생하는 예외는

AuthenticationException을 되던지도록 코드를 짜야한다.

728x90

UserDetailsService

데이터베이스에서 조회한 회원의 인증 정보를 기반으로

인증을 처리하는 커스텀 UserDetailsService를 구현해 보겠다.

 

UserDetailsService는 스프링 시큐리티에서 제공해 주는 컴포넌트로

인증에 필요한 사용자의 정보를 로드하는 인터페이스다.

 

인메모리 데이터베이스를 사용할 때 보았던

InMemoryUserDetailsManager는 UserDetailsManager의 구현체이고

UserDetailsManager는 UserDetailsService를 상속한 인터페이스다.

 

즉, UserDetailsService = UserDetailsManager + UserDetailsService

 

CustomUserDetailsService

@Component
public class MyCustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final CustomAuthorityUtils authorityUtils;
	
    // 의존관계 주입
    public MyCustomUserDetailsService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(
        	() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)
        );

        Collection<? extends GrantedAuthority> authorities = 
        			authorityUtils.createAuthorities(findMember.getEmail());

        return new User(findMember.getEmail(), findMember.getPassword(), authorities);
    }
}

UserDetailsService 인터페이스를 구현한 커스텀 MyCustomUserDetailsService 클래스를 만든다.

 

데이터베이스에서 사용자 인증 정보를 조회해야 하기 때문에 MemberRepository와

조회한 사용자의 권한 정보를 생성할 CustomAuthorityUtils 클래스를 주입받는다.

(CustomAuthorityUtils 클래스는 아직 구현하지 않았다.)

 

loadUserByUsername 추상 메서드를 구현하기 위해 오버라이딩 한다.

Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(
    () -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)
);

우선 데이터베이스에서 사용자 인증 정보를 조회한다

Collection<? extends GrantedAuthority> authorities = 
		authorityUtils.createAuthorities(findMember.getEmail());

CustomAuthorityUtils의 createAuthorities 메서드를 사용해

역할 기반의 권한 정보 컬렉션을 생성한다.

return new User(findMember.getEmail(), findMember.getPassword(), authorities);

UserDetails 인터페이스의 구현체인 User 클래스의 객체를 통해

조회한 인증 정보와 생성한 권한 정보를 스프링 시큐리티에 제공한다.

 

즉, 데이터베이스에서 인증 정보를 조회해서 권한 정보를 생성한 후에

실제 인증 처리는 스프링 시큐리티에 넘겨준다.

 

UserDetails는 UserDetailsService에 의해 로드되어

인증을 위한 핵심 User 정보를 표현하는 인터페이스다.

@Component
public class CustomAuthorityUtils {
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    
    public List<GrantedAuthority> createAuthorities(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }
}

MyCustomUserDetailsService에서 역할 기반의 유저 권한을 생성할 때 사용할

CustomAuthorityUtils 클래스의 코드를 살펴보겠다.

@Value("${mail.address.admin}")
private String adminMailAddress;

@Value 어노테이션을 사용하여 yml 파일에 있는 프로퍼티를 가져와서

관리자용 이메일 주소로 정의한다.

private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");

스프링 시큐리티에서 제공하는 AuthorityUtils 클래스를 사용하여

관리자와 유저의 사용 권한 목록을 생성한다.

public List<GrantedAuthority> createAuthorities(String email) {
    if (email.equals(adminMailAddress)) {
        return ADMIN_ROLES;
    }
    return USER_ROLES;
}

createAuthorities 메서드에서는 파라미터로 전달받은 이메일이

프로퍼티로 가져온 관리자용 이메일 주소와 같다면

관리자 계정으로 만들기 위해 관리자 권한을 부여하고

아니라면 일반 권한을 부여한다.

 

학습을 위한 간단한 코드이기 때문에

실제로는 관리자 권한을 이렇게 간단하게 부여하면 안 된다.

리팩토링

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Optional<Member> optionalMember = memberRepository.findByEmail(username);
    Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

    return new HelloUserDetails(findMember);
}

private final class HelloUserDetails extends Member implements UserDetails {
    HelloUserDetails(Member member) {
        setMemberId(member.getMemberId());
        setFullName(member.getFullName());
        setEmail(member.getEmail());
        setPassword(member.getPassword());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorityUtils.createAuthorities(this.getEmail());
    }

    @Override
    public String getUsername() {
        return 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;
    }
}

기존의 MyCustomUserDetailsService의 loadUserByUsername을

조금 더 유연하게 리팩토링 한 코드이다.

private final class HelloUserDetails extends Member implements UserDetails {
    HelloUserDetails(Member member) {
        setMemberId(member.getMemberId());
        setFullName(member.getFullName());
        setEmail(member.getEmail());
        setPassword(member.getPassword());
    }
    // ...생략
}

기존에 User 객체를 직접 생성하여 리턴하던 역할을 수행하기 위해

UserDetails 클래스를 구현해야 한다.

 

생성자의 파라미터로 데이터베이스에서 조회한 사용자 인증 정보를 받아온다.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorityUtils.createAuthorities(this.getEmail());
}

역할 기반의 권한 정보 컬렉션 생성하던 코드도 옮겨서 오버라이딩 해준다.

@Override
public String getUsername() {
    return getEmail();
}

getUsername 메서드도 이메일을 리턴하는 것으로 오버라이딩 해준다.

 

이외 나머지 오버라이딩 하는 부분은 당장은 크게 중요하지 않은 부분이기에

true 값을 리턴하도록 설정하였다.

 

코드를 리팩토링 하여 기존에 조회한 사용자 정보를 User 객체로 변환하는 과정과

권한 정보를 생성하는 과정을 캡슐화할 수 있게 되었다.

역할을 데이터베이스에서 관리하기

역할을 데이터베이스에서 관리를 하기 위해서는

일단 역할의 권한 정보를 저장할 테이블을 생성하여

회원 가입 시 사용자의 권한 정보를 저장하거나

로그인 시 사용자의 권한 정보를 조회해야 한다.

@Entity
public class Member extends Auditable implements Principal{
	// ...생략
	@ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();
	// ...생략
}

우선은 기존의 엔티티의 매핑을 수정해준다.

 

컬렉션 타입의 필드에 @ElementCollection 어노테이션을 추가하면

권한 정보와 관련된 별도의 엔티티 클래스를 생성할 필요가 없다.

private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
private final List<String> USER_ROLES_STRING = List.of("USER");

public List<String> createRoles(String email) {
    if (email.equals(adminMailAddress)) {
        return ADMIN_ROLES_STRING;
    }
    return USER_ROLES_STRING;
}

CustomAuthorityUtils 클래스에 역할별 권한을 리턴하는

createRoles 메서드를 추가한다.

public void createMember(Member member) {
    String encryptedPassword = passwordEncoder.encode(member.getPassword());
    member.setPassword(encryptedPassword);
	
    // 추가된 부분
    List<String> roles = authorityUtils.createRoles(member.getEmail());
    member.setRoles(roles);

    memberRepository.save(member);
}

기존에 회원 가입을 처리하던 서비스 메서드에

회원의 권한 정보를 데이터베이스 저장하는 코드를 추가하였다.

 

CustomAuthorityUtils에 만들어두었던 createRoles 메서드를 사용하여

해당 사용자의 권한들을 가져온 후에 데이터베이스에 저장한다.

private final class HelloUserDetails extends Member implements UserDetails {
    HelloUserDetails(Member member) {
        setMemberId(member.getMemberId());
        setFullName(member.getFullName());
        setEmail(member.getEmail());
        setPassword(member.getPassword());
        setRoles(member.getRoles());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorityUtils.createAuthorities(this.getRoles());
    }
}

회원가입은 수정했으니 로그인도 위의 코드처럼 수정하면 된다.

 

기존 멤버 엔티티의 수정자 메서드를 사용하던 부분에

권한 정보를 추가하는 수정자 메서드를 추가한다.

 

기존에는 이메일로 권한 목록을 생성하던 createAuthorities 메서드의

파라미터도 데이터베이스에서 가져온 역할로 권한 목록을 생성하게 수정한다.

public List<GrantedAuthority> createAuthorities(List<String> roles) {
   List<GrantedAuthority> authorities = roles.stream()
           .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // (2)
           .collect(Collectors.toList());
   return authorities;
}

바꾼 파라미터에 맞게 createAuthorities 메서드를 오버라이딩 한다.

728x90

패스워드 인코더

클라이언트로부터 받아온 패스워드 같은 민감한 정보는

받아온 그대로 데이터베이스에 저장하는 것이 아니라

저장하기 전에 암호화해줘야 한다.

 

스프링 시큐리티에서는 이러한 암호화 기능을 제공해 주기 때문에

PasswordEncoder 클래스를 사용기만 하면 된다.

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

우선 PasswordEncoder의 구현 객체를 만들기 위해

PasswordEncoderFactories의 createDelegatingPasswordEncoder 메서드를 사용하여

DelegatingPasswordEncoder 구현 객체를 만든다.

@Configuration
public class JavaConfiguration {
    @Bean
    public MemberService inMemoryMemberService(UserDetailsManager userDetailsManager, 
                                               PasswordEncoder passwordEncoder) {
        return new InMemoryMemberService(userDetailsManager, passwordEncoder);
    }
}

Configuration 클래스를 하나 추가한 후에

멤버 서비스 인터페이스의 구현 객체를 빈으로 등록해 준다.

 

이때 멤버 서비스 객체에서는 회원가입, 즉 회원을 생성하는 기능을 수행하기 때문에

암호화된 패스워드를 등록하기 위해 PasswordEncoder 객체를 주입받게 한다.

 

UserDetailsManager 객체는 학습을 위해 인메모리 DB를 사용하기 위해 추가했다.

유저 등록 (인메모리 DB 사용)

public Member createMember(Member member) {
    List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());

    String encryptedPassword = passwordEncoder.encode(member.getPassword());

    UserDetails userDetails = new User(member.getEmail(), encryptedPassword, authorities);

    userDetailsManager.createUser(userDetails);

    return member;
}

위와 같이 멤버 서비스 클래스에 회원가입을 처리하는 메서드를 추가한다.

List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());

한 줄씩 살펴보면 우선 GrantedAuthority 객체를 담는 리스트를 선언하고

createAuthorities 메서드를 통해 받아온다.

 

GrantedAuthority는 회원의 권한으로 회원을 등록하기 위해서는 권한을 지정해야 한다.

 

관리자가 아닌 일반회원의 회원가입을 진행하기 위해 일반회원의 권한을 가져온다.

private List<GrantedAuthority> createAuthorities(String... roles) {
    return Arrays.stream(roles)
            .map(role -> new SimpleGrantedAuthority(role))
            .collect(Collectors.toList());
}

createAuthorities 메서드의 로직은 위와 같다.

 

SimpleGrantedAuthority 생성자의 파라미터로 회원의 역할을 전달하여

객체를 생성한 후에 리스트 형태로 리턴한다.

 

이때 SimpleGrantedAuthority를 사용해 역할 기반 형태의 권한을 지정하려면

ROLE_권한명 형태로 지정해야 적절한 권한 매핑이 이루어진다.

String encryptedPassword = passwordEncoder.encode(member.getPassword());

PasswordEncoder의 encode 메서드를 사용하여

받아온 회원가입 정보에서 비밀번호를 암호화한다.

UserDetails userDetails = new User(member.getEmail(), encryptedPassword, authorities);

User 생성자의 파라미터로 이메일과 암호화된 패스워드와 권한을 전달하고

User 정보를 관리하기 위해 UserDetails를 생성한다.

 

이때 이전 단계에서 패스워드를 암호화하지 않으면 등록은 할 수 있지만

 

userDetailsManager.createUser(userDetails);

UserDetailsManager의 createUser 메서드를 사용하여 User를 등록한다.

유저 등록 (DB 사용)

인메모리 DB를 통해 회원등록이 어떻게 진행되는지 학습했으니

실제 데이터베이스를 사용하여 구현해 보겠다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
        		// 추가 부분
                .headers().frameOptions().sameOrigin()
                // 
                .and()
                .csrf().disable()
                .formLogin()
                .loginPage("/auths/login-form")
                .loginProcessingUrl("/process_login")
                .failureUrl("/auths/login-form?error")
                .and()
                // 로그아웃 시작
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
                // 로그아웃 종료
                .and()
                .exceptionHandling().accessDeniedPage("/auths/access-denied")
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers("/orders/**").hasRole("ADMIN")
                        .antMatchers("/members/my-page").hasRole("USER")
                        .antMatchers("⁄**").permitAll()
                );

        return httpSecurity.build();
    }

우선 기존의 필터체인의 코드를 위와 같이 수정한다.

 

frameOptions 메서드는 HTML의 frame, iframe, object 태그에서

페이지의 랜더링을 허용할지 여부를 결정할 때 사용한다.

 

스프링 시큐리티에서는 기본값으로 클릭재킹 공격을 막기 위해

랜더링을 허용하지 않으려 DENY를 적용한다.

 

sameOrigin() 메서드를 사용하면 동일 출처로부터 들어오는 요청만 랜더링을 허용하는데

현재 코드에서는 H2 데이터베이스를 편리하게 사용하기 위해 부분적으로 허용한다.

@Bean
public MemberService dbMemberService(MemberRepository memberRepository,
                                     PasswordEncoder passwordEncoder) {
    return new DBMemberService(memberRepository, passwordEncoder);
}

이전에는 인메모리 방식의 회원등록을 위해서 UserDetailsManager를 사용하였지만

실제 데이터베이스를 사용할 것이기 때문에 MemberRepository를 추가로 주입받는다.

public void createMember(Member member) {
    String encryptedPassword = passwordEncoder.encode(member.getPassword());
    member.setPassword(encryptedPassword);

    memberRepository.save(member);
}

인메모리DB를 사용하던 createMember 메서드와 마찬가지로

PasswordEncoder를 사용해 암호화된 비밀번호로 수정한 후에

해당 회원을 데이터베이스에 저장하면 된다.

'Back-End > Security' 카테고리의 다른 글

[스프링 시큐리티] 커스텀 로그인 인증 구현  (0) 2023.07.11
[스프링 시큐리티] 커스텀 인증 처리 구현  (0) 2023.07.11
[스프링 시큐리티] 기본  (0) 2023.07.10
Session  (0) 2023.07.07
Cookie  (0) 2023.07.07
728x90

스프링 시큐리티

스프링 MVC/WebFlux 기반 애플리케이션의 인증인가 기능을 지원하는 보안 프레임워크

 

주로 아래와 같은 기능들을 제공한다.

  • 다양한 유형의 사용자 인증 기능 적용 (폼 로그인, 토큰 기반, OAuth2 기반 등의 인증)
  • 사용자의 역할에 따른 권한 레벨 적용
  • 리소스에 대한 접근 제어
  • 민감한 정보의 데이터 암호화
  • SSL 적용
  • 일반적으로 알려진 공격 차단
  • SSO
  • 클라이언트 인증서 기반 인증
  • 메서드 보안
  • 접근 제어 목록

위에 언급한 것 외에도 많은 기능들을 제공한다.

 

사용해 보기

implementation 'org.springframework.boot:spring-boot-starter-security'

gradle 설정에 스프링 시큐리티 의존성을 추가해 준다.

해당 의존성만 추가한 후에 서버를 실행시키면

별도의 설정 없이도 위와 같은 로그인 창이 만들어진다.

2023-07-10 11:29:25.816  WARN 5668 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 32548629-b669-4cb1-af28-6595d2914bfc

This generated password is for development use only. Your security configuration must be updated before running your application in production.

로그인을 위해서는 기본 값으로는

Username에 user를 적고

Password에는 콘솔창에 나오는 비밀번호를 사용하면 된다.

@Configuration
public class SecurityConfiguration {

    @Bean
    public UserDetailsManager userDetailsManager() {
        UserDetails userDetails =
                User.withDefaultPasswordEncoder()
                        .username("da9dac")
                        .password("root")
                        .roles("USER")
                        .build();

        return new InMemoryUserDetailsManager(userDetails);
    }
}

하지만 기본 비밀번호는 서버를 껐다 킬 때마다 바뀌기 때문에

위와 같은 작업을 통해 Username과 Password를 고정시킬 수 있다.

 

당장은 학습용으로만 사용할 것이기 때문에

InMemoryUserDetailsManager를 사용한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
    		// CSRF 공격에 대한 설정 비활성화 (학습을 위해 비활성화)
            .csrf().disable() // 기본값은 enable
            .formLogin() // 인증 방식을 폼 로그인 방식으로 지정
            .loginPage("/auths/login-form") // 로그인 페이지로 사용할 템플릿
            .loginProcessingUrl("/process_login") // 인증 요청을 수행할 요청 URL
            .failureUrl("/auths/login-form?error") // 실패할 경우 사용할 템플릿
            .and()
            .authorizeHttpRequests() // Http 요청이 들어오면 접근 권한을 확인
            .anyRequest().permitAll(); // 클라이언트의 모든 요청에 대한 접근 허용

    return httpSecurity.build();
}

만약 별도의 커스텀 로그인 페이지를 사용하고 싶다면 위와 같은 코드를

SecurityConfiguration 클래스에 추가해 주면 된다.

 

해당 메서드의 파라미터로 받아오는 HttpSecurity 클래스는

HTTP 요청에 대한 보안 설정을 구성하는 클래스다.

 

해당 객체를 이용해 자신에게 필요한 보안 옵션을 적용할 수 있다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            .csrf().disable()
            .formLogin()
            .loginPage("/auths/login-form")
            .loginProcessingUrl("/process_login")
            .failureUrl("/auths/login-form?error")
            .and()
            .exceptionHandling().accessDeniedPage("/auths/access-denied")
            .and()
            .authorizeHttpRequests(
                    authorize -> authorize
                            .antMatchers("/orders/**").hasRole("ADMIN")
                            .antMatchers("/members/my-page").hasRole("USER")
                            .antMatchers("⁄**").permitAll()
            );

    return httpSecurity.build();
}

위의 코드는 기존의 코드에서 exceptionHandling 메서드를 추가하고

authorizeHttpRequests 메서드의 파라미터 부분만 바꾼 코드이다.

.exceptionHandling().accessDeniedPage("/auths/access-denied")

우선 exceptionHandling 메서드는 예외를 처리하는 기능을 하는데

ExceptionHandlingConfigurer를 리턴하고 이를 통해 예외 처리를 할 수 있다.

 

ExceptionHandlingConfigurer객체의 accessDeniedPage 메서드를 사용해

403 에러가 발생한 경우 지정한 URL로 리다이렉트 되도록 했다.

.authorizeHttpRequests(
        authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry
                    .antMatchers("/orders/**").hasRole("ADMIN")
                    .antMatchers("/members/my-page").hasRole("USER")
                    .antMatchers("⁄**").permitAll()
);

authorizeHttpRequests 메서드는 람다식을 사용해 request URI에 접근 권한을 부여할 수 있다.

 

antMatcher 메서드와 hasRole 메서드를 사용하여 페이지 별로 권한을 정할 수 있는데

antMatcher 메서드의 파라미터에는 어떤 URI를 매치할지 정하고

hasRole 메서드의 파라미터에는 어떤 권한만 허용할지 지정한다.

 

antMatcher 메서드에서 "/orders/**"와 같은 부분은

orders라는 URL의 하위 모든 URL을 포함하지만

만약 "/orders/*" 라면 orders의 깊이가 1인 URL까지만 포함한다.

.antMatchers("⁄**").permitAll()

마지막 부분은 위에서 지정한 모든 URL 이외의 URL의 권한을 정하는 부분으로

이외의 모든 URL은 모두가 접근이 가능하다는 의미다.

 

antMatchers 메서드의 순서에 따라 우선순위가 적용되기 때문에

구체적인 URL 경로부터 접근 권한을 부여한 후에

마지막 부분처럼 모든 URL을 처리하는 부분은 가장 나중에 적용해야 한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable()
        .formLogin()
        .loginPage("/auths/login-form")
        .loginProcessingUrl("/process_login")
        .failureUrl("/auths/login-form?error")
        .and()
        // 로그아웃 시작
        .logout()
        .logoutUrl("/logout")
        .logoutSuccessUrl("/")
        // 로그아웃 종료
        .and()
        .exceptionHandling().accessDeniedPage("/auths/access-denied")
        .and()
        .authorizeHttpRequests(authorize -> authorize
                .antMatchers("/orders/**").hasRole("ADMIN")
                .antMatchers("/members/my-page").hasRole("USER")
                .antMatchers("⁄**").permitAll()
        );
    return http.build();
}

로그인에 대한 기능을 추가했으면 로그아웃도 필요하다.

 

위의 코드에서 로그아웃 설정을 위해 logout 메서드를 호출하여 LogoutConfigurer를 리턴하는데

logoutUrl 메서드를 사용해 로그아웃에 사용할 요청 URL을 지정해 주고

logoutSuccessUrl 메서드를 사용해 로그아웃 성공 시 리다이렉트 할 URL을 지정해 준다.

 

'Back-End > Security' 카테고리의 다른 글

[스프링 시큐리티] 커스텀 인증 처리 구현  (0) 2023.07.11
[스프링 시큐리티] 회원가입 구현  (0) 2023.07.10
Session  (0) 2023.07.07
Cookie  (0) 2023.07.07
Hashing  (0) 2023.07.07
728x90

세션 기반 인증

세션 : 사용자가 인증에 성공한 상태로 세션 스토어나 인메모리 DB에 저장 됨

세션 아이디 : 각 세션을 구분할 수 있는 기본키 같은 역할

 

로그인

1. 만약 사용자가 로그인을 요청한 후에 성공했을 경우

서버는 세션을 만들어 저장한 후에 세션의 아이디를 클라이언트에 전달

 

2. 로그인을 유지할 수단으로 쿠키를 사용하며

이때 쿠키에 세션의 아이디를 저장해둔다.

 

3. 쿠키에 저장된 세션 아이디를 서버에 전달하면서

세션 스토어에 저장하는 세션이면 로그인 상태가 검증되기 때문에

쿠키와 세션을 사용해 로그인 상태를 유지할 수 있다.

 

로그아웃

세션은 로그인 성공에 대한 증명 정보를 가지고 있는

일종의 서버 측에 자유롭게 드나들 수 있는 프리패스권과 같은 개념이다.

 

그래서 로그아웃을 하기 위해서는 서버 측에서는 해당 세션의 정보를 삭제하고

클라이언트 측에서는 쿠키를 갱신하면 된다.

 

서버가 클라이언트의 쿠키를 삭제할 수는 없기 때문에

set-cookie를 사용해 클라이언트에 쿠키를 전송할 때

세션 아이디의 키값을 무효한 값으로 갱실할 수 있다.

'Back-End > Security' 카테고리의 다른 글

[스프링 시큐리티] 회원가입 구현  (0) 2023.07.10
[스프링 시큐리티] 기본  (0) 2023.07.10
Cookie  (0) 2023.07.07
Hashing  (0) 2023.07.07
HTTPS  (0) 2023.07.07

+ Recent posts