AuthenticationProvider 인터페이스가 인증 논리를 담당한다.

 

AuthenticationProvider 이해

실제 인증은 여러 방식이 있을 있다.

단순한 id/pw 인증이 전부가 아니다.

 

스프링 시큐리티는 AuthenticationProvider 모든 인증을 추상화하고, 이게 맞게 구현만 하면 어떠한 인증 시나리오도 구축할 있다.

 

인증에 사용되는 Authentication

Authentication 인증에 사용되는 필수 인터페이스다.

아래 처럼 인자로 사용되어 인증에 사용된다.

 

Authentication Principal 확장한다. Principal 자바 시큐리티 인터페이스다.

 

 

인증 논리 구현

authenticate() 인증 성공 완전히 인증된 Authentication 반환해야 한다.

isAuthenticated() true 반환하고, 모든 필수 정보가 포함되야한다.

, 보안 상의 이유로 자격증명은 제거될 있다.

인증이 이미 성공했기 때문에 이상 자격증명 같은 민감한 정보가 필요없기 때문이다.

 

supports() 매개변수 Authentication 인증 사용할 있는지 여부를 리턴한다.

주의할 것은 메서드가 true 리턴해도 authenticate()메서드에서 null 리턴해 인증을 거부할 있다.

supports() false 경우 AuthenticaitonManager 인증을 시도하지 않는다. 바로 다음 AuthenticationProvider에게 인증을 위임한다.

 

맞춤형 인증 논리 구현

 

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.security.authentication.AuthenticationProvider;

import org.springframework.security.authentication.BadCredentialsException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

import org.springframework.security.core.Authentication;

import org.springframework.security.core.AuthenticationException;

import org.springframework.security.core.userdetails.UserDetails;

import org.springframework.security.core.userdetails.UserDetailsService;

import org.springframework.security.crypto.password.PasswordEncoder;

import org.springframework.stereotype.Component;

 

@Component

public class CustomAuthenticationProvider implements AuthenticationProvider{

        Logger logger = LoggerFactory.getLogger(getClass());

        

        @Autowired

        private UserDetailsService userDetailsService;

        

        @Autowired

        private PasswordEncoder passwordEncoder;

        

        @Override

        public Authentication authenticate(Authentication authentication) throws AuthenticationException {

                logger.info("=========== 인증 시작 ===========");

                

                String username = authentication.getName();

                String password = authentication.getCredentials().toString();

                

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                

                if(passwordEncoder.matches(password, userDetails.getPassword())) {

                        return new UsernamePasswordAuthenticationToken(

                                        username, password, userDetails.getAuthorities());

                }

             

                throw new BadCredentialsException("인증 실패");

        }

        @Override

        public boolean supports(Class<?> authentication) {

                return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);

        }

}

 

참고로 권한 정보까지 사용해서 UsernamepasswordAuthenticationToken 생성하면

아래와 같이 완전히 인증됨을 의미하는 플래그 값을 true 설정한다.

실행

 

 

 

SecurityContext 이용

SecurityContext(이하 보안 컨텍스트)

 

인증 프로세스가 끝난 인증된 Authentication 대한 세부 정보가 필요할 가능성이 크다.

주로 이름과 권한을 지속적으로 확인해야할 있다.

SecurityContext Authentication 객체가 요청이 유지되는 동안 내부에 저장한다.

 

 

 

보안 컨텍스트는 이게 전부다. 보면 있듯 인증 객체를 저장하는 것이 역할이다.

 

보안 컨텍스트를 관리하는 SecurityContextHolder는 3 가지 모드를 지원한다.

 

MODE_THREADLOCAL

스레드에 안전한 저장소에 보안 문맥을 저장한다.

일반적으로 WAS 하나의 요청 하나의 쓰레드를 가지므로 기본이 되는 전략이다.

 

MODE_INHERITABLETHREADLOCAL

MODE_THREADLOCAL와 유사하다.

비동기 메서드의 경우, 다음 스레드로 복사해서 사용한다.

예를들어, @Async 메서드를 실행하는 스레드가 보안 컨텍스트를 상속한다.

 

MODE_GLOBAL

애플리케이션의 모든 스레드가 같은 보안 컨텍스트를 공유한다.

 

스프링 시큐리티는 이외에도 사용자가 커스텀한 전략을 사용하도록 설정할 있다.

 

보안 컨텍스트를 위한 보유 전략 이용

MODE_THREADLOCAL

기본 전략은 MODE_THREADLOCAL 이다.

내부적으로 ThreadLocal 사용한다.

ThreadLocal 저장된 데이터는 다른 쓰레드에선 접근이 불가능하다.

이는 요청마다 새로운 쓰레드를 생성하는 일반적인 WAS 맞아떨어지는 동작방식이다.

 

만약 서버가 비동기 기술을 사용한다면 문제가 있다. 기존 쓰레드에서 새로운 쓰레드를 생성해 실행하면, 새로 생성된 쓰레드는 접근을 하지 못하기 때문이다.

6번에 저장된 보안 컨텍스트를 7번에서 꺼내서 사용할 있다.

 

 

 

MODE_INHERITABLETHREADLOCAL

비동기를 사용하는 상황에서 전략

 

비동기 설정

 

 

 

아직 보안 컨텍스트 모드를 설정하지 않아 기본 모드인 MODE_THREADLOCAL 동작하기에 값이 없다.

 

InitializingBean 빈을 구현하면, 스프링이 컨테이너를 초기화하는 과정에 호출해준다.

 

 

정상적으로 값이 나온다.

 

주의할 것은 방법은 선언적으로 작성한 코드에 대해서만 동작을 한다.

선언적으로 작성한 코드는 프레임워크에서 런타임 중에 비동기임을 알기 때문에 자동으로 처리가 된다.

 

개발자가 메서드 안에서 코드로 쓰레드를 만들어 호출하면, 프레임워크는 비동기가 발생한지 없어, Null 나오게 된다.

 

MODE_GLOBAL

보안 컨텍스트가 모든 스레드에 공유된다.

 

모든 스레드가 공유하기 때문에 이게 따른 동시성 이슈를 개발자가 직접 해결해야 한다.

 

 

 

DelegatingSecurityContextRunnable 보안 컨텍스트 전달

보안 컨텍스트 저장 전략은 가지로 기본 모드는 MODE_THREADLOCAL 이다

모드는 하나의 요청 마다 새로운 쓰레드를 사용하는 보편적인 서버에서 사용된다.

상황에서 비동기 작업을 하는 경우, 개발자가 직접 쓰레드로 보안 컨텍스트를 전파하는 작업을 해줘야 한다

 

이를 위해 스프링이 제공하는 유틸들이 있다.

DelegatingSecurityContextRunnable 반환 값이 없는 경우 사용

DelegatingSecurityContextCallable 반환 값이 있는 경우 사용한다.

 

 

 

 

비동기 작업을 비동기 메서드를 통해, 쓰레드로 복사한 실행한다.

실행

 

 

 

DelegatingSecurityContextExecutorService

DelegatingSecurityContextCallable, DelegatingSecurityContextRunnable은 동기화 모드에서 비동기 메서드를 사용해야 보안 컨텍스트를 복사해주는 유용한 클래스다.

 

이보다 우아한 방법으로 DelegatingSecurityContextExecutorService는 스레드 풀에서 전파 작업을 해준다.

 

 

 

 

 

스레드 풀에서 새로운 스레드에 보안 컨텍스트를 전파해 준다.

이것도 기존 클래스에 기능을 데코레이터 패턴이 적용된 클래스다.

 

보안 컨텍스트를 전파해주는 스레드 클래스

 

보안 컨텍스트를 전파해주는 클래스

DelegatingSecurityContextCallable, DelegatingSecurityContextRunnable

 

인증 실패 제어

클라이언트가 인증에 실패하거나, 액세스 권한이 없는 리소스에 인증되지 않은 요청을 하는 경우가 있다.

이 경우 클라이언트로부터 자격 증명을 요청하는 데 AuthenticationEntryPoint 구현이 사용된다.

AuthenticationEntryPoint 구현은 로그인 페이지로 리디렉션을 수행하거나, WWW-Authenticate 헤더로 응답하거나, 다른 작업을 수행할 수 있다.

 

일반적으로 ExceptionTranslationFilter에 인증 체계를 시작하기 전에 호출된다.

 

 

 

import java.io.IOException;


import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;


import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;


public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint{


        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException authException) throws IOException, ServletException {
                response.addHeader("message", "Authentication Failed");
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
        }


}

 

import javax.sql.DataSource;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;


import com.springsecurity.authentication.CustomAuthenticationEntryPoint;


@Configuration
public class SecurityConfig {
        
        @Bean
        SecurityFilterChain config(HttpSecurity http) throws Exception {
                http.httpBasic(c -> {
                        c.realmName("ADMIN");
                        c.authenticationEntryPoint(new CustomAuthenticationEntryPoint());
                });
                http.authorizeHttpRequests(request -> {
                        request.anyRequest().authenticated();
                });
                
                return http.build();
        }
        
   
    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    @Bean
    UserDetailsService userDetailsService(DataSource dataSource) {
            return new JdbcUserDetailsManager(dataSource);
    }
}

 

 

인증 실패 해보기

 

 

 

 

Form 기반 로그인 인증 구현

일반적인 사용자가 ID/PW 입력해서, 인증하는 방식을 실습해본다.

 

 

위와 같이 설정 서버를 키고 접속하면, 스프링이 기본으로 제공하는 로그인 페이지가 뜬다.

 

 

이전처럼 RestController 아닌 일반 Controller 실습을 하기 위해 간단한 html파일 생성

 

 

 

 

 

 

 

로그인 성공/실패 처리할 핸들러

성공은 AuthenticationSuccessHandler, 실패는 AuthenticationFailureHandler

각각 구현해서 아래에 메서드에 등록해 주면 된다.

 

 

 

form 타입은 FormLoginConfigurer<HttpSecurity>으로 양식 인증 관련 다양한 설정을 있다.

 

 

AuthenticationSuccessHandler, AuthenticationFailureHandler  구현

import java.io.IOException;


import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;


import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;


@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{


        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                        Authentication authentication) throws IOException, ServletException {
                var auth = authentication.getAuthorities()
                        .stream()
                        .filter(a -> a.getAuthority().equals("read"))
                        .findFirst();
                
                if(auth.isEmpty()) {
                        //권한 없음
                        response.sendRedirect("/error");
                } else {
                        //권한 존재
                        response.sendRedirect("/home");
                }
        }
}

 

import java.io.IOException;
import java.time.LocalDateTime;


import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;


import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;


public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException exception) throws IOException, ServletException {
                
                response.addHeader("failed", LocalDateTime.now().toString());
        }
}

 

import java.io.IOException;
import java.time.LocalDateTime;


import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;


import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;


@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException exception) throws IOException, ServletException {
                
                response.addHeader("failed", LocalDateTime.now().toString());
        }
}

 

import java.io.IOException;


import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;


import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;


@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint{


        public void commence(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException authException) throws IOException, ServletException {
                response.addHeader("message", "Authentication Failed");
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
        }


}

 

 

 
 

import javax.sql.DataSource;



import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

org.springframework.security.config.annotation.web.builders.HttpSecurity를 ​​가져옵니다.

org.springframework.security.core.userdetails.UserDetailsService 가져오기;

org.springframework.security.crypto.password.NoOpPasswordEncoder 가져오기;

org.springframework.security.crypto.password.PasswordEncoder 가져오기;

org.springframework.security.provisioning.JdbcUserDetailsManager 가져오기;

org.springframework.security.web.SecurityFilterChain 가져오기;



import com.springsecurity.authentication.CustomAuthenticationEntryPoint;



@구성

공개 클래스 SecurityConfig {



@콩

SecurityFilterChain 구성(HttpSecurity http)에서 예외가 발생합니다.

http.http기본(c -> {

c.realmName("관리자");

c.authenticationEntryPoint(new CustomAuthenticationEntryPoint());

});

http.authorizeHttpRequests(요청 -> {

request.anyRequest().authenticated();

});



http.build()를 반환합니다.

}





@콩

PasswordEncoder 비밀번호Encoder() {

NoOpPasswordEncoder.getInstance()를 반환합니다.

}

@콩

UserDetailsService userDetailsService(DataSource dataSource) {

새로운 JdbcUserDetailsManager(dataSource)를 반환합니다.

}

}

 

PasswordEncoder

사용자 세부정보를 찾은 후에 요청에서 얻은 자격증명과 사용자 세부정보에 자격증명과 비교를 한다.

 

정의

 

encode() 주어진 자격증명을 해시나 암호화를 한다.

amtches() 주어진 자격증명과 암호화나 해시화된 자격증명과 일치하는 확인한다.

upgrageEncoding() 경우 인코딩된 비밀번호를 다시 인코딩해야 하는 경우 true를 반환하도록 재정의한다.

 

 

 

단순한 구현

암호기능 없음

public class PlainTextPasswordEncoder implements PasswordEncoder{

        public String encode(CharSequence rawPassword) {

                return rawPassword.toString();

        }

        public boolean matches(CharSequence rawPassword, String encodedPassword) {

                return Objects.equals(rawPassword, encodedPassword);

        }

}

테스트

 

class SpringSecurity04PasswordApplicationTests {

 

        @Test

        void 단순_암호_인코더_테스트() {

                PlainTextPasswordEncoder encoder = new PlainTextPasswordEncoder();

                String password = "tiger";

                

                assertThat(encoder.matches(password,encoder.encode(password))).isTrue();

        }

}

 

학습 테스트를 위한 구현이지, 실제로는 이미 구현된 클래스를 가져다 쓴다.

암호화 알고리즘 기능은 개발자가 구현할 일도 없다.

 

나머지 테스트

 

SCryptPasswordEncoder 추가로 의존하는 라이브러리가 있다.

 

 

 

 

DelegatingPasswordEncoder

자격증명을 검증할 다양한 알고리즘이 필요한 경우 사용한다.

 

자격증명 앞에 접두로 "{알고리즘}" 붙여 상황에 맞게 알고리즘을 실행한다.

 

예시

{noop}tiger , 인코딩하지 않는다.

{bcrypt}tiger , BCryptPasswordEncoder로 인코딩한다.

 

BCryptPasswordEncoder는 기능 구현을 하지 않고, 모든 책임을 구성 PasswordEncoder 에게 위임한다.

 

사용해보기

직접 생성하지 않고, 스프링 시큐리티가 제공하는 팩터리에서 생성해서 사용할 있다.

 

 

 

 

CustomPassword 추가

만일 커스텀한 PasswordEncoder 있다면, 직접 생성해야 한다.

 

createDelegatingPasswordEncoder() 메서드를 그대로 복사해, 커스텀 구현체를 넣어 사용하면 된다.

 

 

 

 

암호화 생성

암호화 복호화 함수와 생성 기능은 자바 언어에서 기본 제공되지 않는다.

스프링 시큐리티는 이를 위해 자체 솔루션을 제공한다.

 

생성기

문자열 기반, 바이트 기반 가지 인터페이스가 존재한다.

 

 

/복호화

 

 

사용해보기

 

import java.nio.charset.StandardCharsets;

import java.util.Arrays;

import java.util.Objects;

 

import org.springframework.security.crypto.encrypt.BytesEncryptor;

import org.springframework.security.crypto.encrypt.Encryptors;

import org.springframework.security.crypto.encrypt.TextEncryptor;

import org.springframework.security.crypto.keygen.KeyGenerators;

import org.springframework.security.crypto.keygen.StringKeyGenerator;

 

public class Test2 {

        public static void main(String[] args) {

                stringBasedEncrption();

                System.out.println();

                byteBasedEncrption();

        }

 

        private static void stringBasedEncrption() {

                System.out.println("문자열 기반 암호화");

                StringKeyGenerator keyGenerator = KeyGenerators.string();

                

                String password = "tiger";

                String solt = keyGenerator.generateKey();

                

                //암호화 하지 않음

//                TextEncryptor encryptor = Encryptors.noOpText();

                //기본

//                TextEncryptor encryptor = Encryptors.text(password, solt);

                //더 강력

                TextEncryptor encryptor = Encryptors.delux(password, solt);

                

                String plainText = "민감한 데이터";

                //encrypt() 매 실행 마다 값이 다르다. 내부적으로 난수를 사용한다.

                String cypherText1 = encryptor.encrypt(plainText);

                String cypherText2 = encryptor.encrypt(plainText);

                

                System.out.println("plainText = " + plainText);

                System.out.println("cypherText1 = " + cypherText1);

                System.out.println("cypherText2 = " + cypherText2);

                System.out.println(encryptor.decrypt(cypherText1));

                System.out.println(Objects.equals(plainText, encryptor.decrypt(cypherText1)));

        }

        private static void byteBasedEncrption() {

                System.out.println("바이트 기반 암호화");

                StringKeyGenerator keyGenerator = KeyGenerators.string();

                

                String password = "민감한 데이터";

                String solt = keyGenerator.generateKey();

                

                //기본

//                BytesEncryptor encryptor = Encryptors.standard(password, solt);

                //더 강력

                BytesEncryptor encryptor = Encryptors.stronger(password, solt);

                

                byte[] plainByte = password.getBytes(StandardCharsets.UTF_8);

                //encrypt() 매 실행 마다 값이 다르다. 내부적으로 난수를 사용한다.

                byte[] cypherByte1 = encryptor.encrypt(plainByte);

                byte[] cypherByte2 = encryptor.encrypt(plainByte);

                

                System.out.println("plainByte" + Arrays.toString(plainByte));

                System.out.println("cypherByte1 = " + Arrays.toString(cypherByte1));

                System.out.println("cypherByte2 = " + Arrays.toString(cypherByte2));

                System.out.println(new String(encryptor.decrypt(cypherByte1), StandardCharsets.UTF_8));

                System.out.println(Objects.equals(plainByte, encryptor.decrypt(cypherByte1)));

        }

}

 

 

 

 

 

spring-security-04-password.zip
0.15MB

 

사용자 관리

스프링 시큐리티에서 사용자 관리는 UserDetailsService 담당한다

사용자를 찾는 역할만 한다.

 

UserDetailsManager 인터페이스를 확장해 User 대한 CUD 기능들을 지원한다.

인터페이스의 상속 관계는 인터페이스 분리 원칙의 모범 사례다.

 

조회만 필요한 경우와 조회된 사용자에 대한 조작이 필요한 경우 나눠서 알맞게 구현하면 된다.

 

UserDetailsService.loadUserByUsername() 조회 결과로 반환되는 UserDetails는 사용자에 대한 정보가 들어있다.

이중 권한에 대한 표현은 GrantedAuthority로 한다. 그리고 권한은 복수일 있어 Collection으로 다룬다.

 

사용자 기술하기

UserDetials

UserDetails을 알맞게 기술하면 스프링 시큐리티는 사용자를 인식할 있다.

헛갈릴 있어 일부러 Authentication 맞게 코맨트를 달았다.

isAccountNonExpired() 같이 이중 부정으로 보여 헷갈린다면,

is-- 4 가지 전부 true일 때 유효하다고 기억하면 된다.

만약 계정 만료, 계정 잠금, 자격 증명 만료, 계정 /활성화 기능이 필요 없다면, 단순히 구현부를 true 리턴하게 만들면 된다.

 

 

 

 

 

authentication 메서드 안에서 UserDetails 사용하고, 메서드 결과를 반환할 때는 Authentication으로 반환한다.

 

아래는 AbstractUserDetailsAuthenticationProvider의 구현부다.

 

 

메서드는 추상 메서드로 템플릿 메서드 패턴이 적용되어 있다.

 

클래스 확장 클래스는 DaoAuthenticationProvider .

메서드에서 아래 그림과 같은 관계를 구현하고 있다.

 

 

GrantedAuthority

사용자에게 허가된 권한을 반환하는 역할을 한다.

사용자에게 권한은 여러 개일 있으므로 Collection으로 관리된다.

 

 

GrantedAuthority 추상 메서드가 하나 뿐인 함수형 인터페이스지만 @FunctionalInterface 애노테이션이 붙지 않았다.

따라서 가급적 람다를 통한 객체 생성을 지양하고, 구현체를 써야 한다.

테스트 같은 간단한 구현에선 람다를 사용하는 것은 상관없다.

 

 

UserDetails 구현

최소한의 구현

 

빌더 사용

 

어떠한 방법을 사용하건 UserDetails 인터페이스 맞게 값만 채워서 리턴하면 된다.

보통은 DB에서 조회해서 값을 채우게 것이다.

 

 

JPA UserDetails 구현

하나의 UserDetails 구현체에 JPA 엔티티 책임과 UserDetails 책임 가지를 함께 구현하면 단일 책임 원칙에 위배되며, 코드가 복잡해진다.

따라서 책임을 분리해 엔티티는 엔티티만, UserDetails 역할은 UserDetails 구현체가 하도록 하면 된다.

 

JPA 엔티티 책임만

스프링 시큐리티가 원하는 UserDetails 책임만

 

JPA 사용하는 코드와 스프링 시큐리티가 사용하는 코드가 분리됐다.

 

 

 

스프링 시큐리티가 사용자를 관리하는 방법 지정

스프링이 이해하는 UserDetails 만드는 법을 알았으니,

다음은 정보를 가져오는 방법을 알아본다.

 

 

 

UserDetailsService 구현

스프링 시큐리티가 제공하는 InMemoryUserDetailsManager 착각하면 안된다.

 

 

 

 

 

JdbcUserDetailsManager 사용

JdbcUserDetailsManager 직접 데이터베이스에 접근해서 데이터를 가져온다.

이를 위해 도커엔진 위에 Mysql 설치했다.

 

사용한 버전, password

docker run --name mysql8 -e MYSQL_ROOT_PASSWORD=root -d mysql:8.0.27

 

 

스프링 부트는 classpath 경로에 schema.sql, data.sql 있으면, 부팅하면서 대신 실행해 준다.

, application.properties "spring.sql.init.mode=always" 설정돼야 실행된다.

application.properties

spring.datasource.url=jdbc:mysql://localhost/spring?useUnicode=true&serverTimezone=Asia/Seoul

spring.datasource.username=root

spring.datasource.password=root

spring.sql.init.mode=always

 

 

data.sql

INSERT INTO `spring`.`authorities` VALUES (NULL, 'scott', 'write');

INSERT INTO `spring`.`users` VALUES (NULL, 'scott', 'tiger', '1');

 

schema.sql

CREATE DATABASE spring default CHARACTER SET UTF8;

 

CREATE TABLE IF NOT EXISTS `spring`.`users` (

  `id` INT NOT NULL AUTO_INCREMENT,

  `username` VARCHAR(45) NULL,

  `password` VARCHAR(45) NULL,

  `enabled` INT NOT NULL,

  PRIMARY KEY (`id`));

CREATE TABLE IF NOT EXISTS `spring`.`authorities` (

  `id` INT NOT NULL AUTO_INCREMENT,

  `username` VARCHAR(45) NULL,

  `authority` VARCHAR(45) NULL,

  PRIMARY KEY (`id`));

그림에서 UserDetailsService JdbcUserDetailsManager이다.

 

이제 서버 기동 테스트를 한다.

 

데이터베이스 확인

 

 

통신 확인

 

 

spring-security-02-user.zip
0.09MB
spring-security-03-user.zip
0.08MB

 

HTTP Basic

기본 구성해보기

openssl req -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365

프로젝트 생성

3가지 Starter 추가

 

컨트롤러 추가

생성 간단한 RestController 만든다.

 

 

동작확인

아무 것도 안한 상태에서 실행하면,

스프링 시큐리티가 기본 계정을 생성해준다.

ID : user  고정

PW : UUID , 실행 마다 변경된다.

 

여기서 API 툴로 API Tester를 사용했다.

위와 같이 401 코드를 반환한다.

 

Add authorization 눌러 아래와 같이 입력한다.

pw 스프링 실행 출력된 값이다.

 

이제 성공한다.

 

Basic 뒤에 값을 복사해서 아래 사이트에 붙여넣고 Decode 해보면,

ID, PW 그대로 노출된 것을 있다.

 

https://www.base64decode.org/

위와 같은 문제로, HTTP Basic 실제 현업에서 사용할 일이 없다.

 

흐름은 UsernamePasswordAuthenticationFilter 추가적으로 첨가한 그림이다.

그림을 보면, 모든 관계가 Interface 통해 이뤄지고 있다.

, 어떤 Filter 인증을 하던 간에 위와 같다.

 

AuthenticationProvider 의존하는 인터페이스는 다음과 같은 책임을 가지고 있다.

UserDetailsSerivce 주어진 username(id) 저장소에 존재하는지 확인

PasswordEncoder 주어진 자격증명(password) 자장소에 저장된 값과 같은지

 

 

기본구성 재정의

UserDetailsService, PasswordEncoder

 

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
        
        //책임_저장소에서 해당 유저가 존재하는지 확인 및 유저 권한 조회
        @Bean
        UserDetailsService userDetailsService() {
                var userDetailsService = new InMemoryUserDetailsManager();        
                userDetailsService.createUser(
                                User.withUsername("scott")
                                        .password("tiger")
                                        .passwordEncoder(pw -> passwordEncoder().encode(pw))
                                        .authorities(List.of())
                                        .build()
                                );
                
                return userDetailsService;
        }
        //책임_Password를 해싱, 입력된 Password를 해싱된 password와 비교하여 일치하는지 확인
        @Bean
        PasswordEncoder passwordEncoder() {
                return new BCryptPasswordEncoder();
        }
        
        @Bean
        SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
                http
                        .authorizeHttpRequests(authorize -> authorize
                                .anyRequest().authenticated() //모든 경로는 인증돼야 함
                        );
                http.httpBasic(Customizer.withDefaults());
                
                return http.build();
        }
}

변경된 구성 동작 확인

 

주의사항

UserDetailsService 재정의할 반드시 PasswordEncoder 재정의해야한다.

PasswordEncoder password 검증하려고, 하는데 없기 때문이다.

정확히는 아무 PasswordEncoder 등록되지 않을 등록되는 더미 UnmappedIdPasswordEncoder 예외를 던진다.

 

커스텀 AuthenticationProvider 해보기

 

 



@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{
        Logger logger = LoggerFactory.getLogger(getClass());
        
        @Override
        public Authentication authenticate(Authentication authentication)
                        throws AuthenticationException {
                
                String username = authentication.getName();
                String password = authentication.getCredentials().toString();
                
                logger.info("username = " + username + ", password = " + password);
                
                if(!"scott".equals(username) || ! "tiger".equals(password)) {
                        throw new UsernameNotFoundException("인증 실패");
                }
                
                return new UsernamePasswordAuthenticationToken(username, password, List.of());
        }


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


        @Bean
        SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
                http
                        .authorizeHttpRequests(authorize -> authorize
                                .anyRequest().authenticated() //모든 경로는 인증돼야 함
                        );
                //인증 활성화
                http.httpBasic(Customizer.withDefaults());
                
                //커스텀 인증 제공자 등록
                http.authenticationProvider(new CustomAuthenticationProvider());
                
                return http.build();
        }

동작 확인

 

커스텀 구성에서 PasswordEncoder UserDetailsService 사용하지 않았다.

실제로는 이렇게 구성하면 안된다.

프레임워크에 설계된 아키텍처 그대로를 지켜야 한다.



@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{
        private final Logger logger = LoggerFactory.getLogger(getClass());
        @Autowired
        private PasswordEncoder encoder;
        @Autowired
        private UserDetailsService userDetailsService;


        @Override
        public Authentication authenticate(Authentication authentication)
                        throws AuthenticationException {
                
                String username = authentication.getName();
                String password = authentication.getCredentials().toString();
                
                logger.info("username = " + username + ", password = " + password);
                
//                simpleAuthentication(username, password);
                
                UserDetails user = userDetailsService.loadUserByUsername(username);
                if(user == null || !encoder.matches(password, user.getPassword())) {
                        throw new UsernameNotFoundException("인증 실패");
                }
                return new UsernamePasswordAuthenticationToken(username, password, List.of());
        }


        private void simpleAuthentication(String username, String password) {
                if(!"scott".equals(username) || ! "tiger".equals(password)) {
                        throw new UsernameNotFoundException("인증 실패");
                }
        }


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

테스트 코드



@SpringBootTest
@AutoConfigureMockMvc
class SpringSecurity01ApplicationTests {
        
        @Autowired
        private MockMvc mvc;
        
        @Autowired
        private PasswordEncoder encoder;
        
        @Test
        void HTTP_BASIC_인증실패() throws Exception {
                mvc.perform(get("/resource"))
                        .andExpect(status().isUnauthorized()); // 401
        }
        @Test
        void HTTP_BASIC_인증성공() throws Exception {
                mvc.perform(get("/resource")
                                        .with(httpBasic("scott", "tiger")))
                                .andExpect(content().string("resource"))
                                .andExpect(status().isOk());
        }
        @Test
        @WithMockUser(username = "root", password = "root")
        void HTTP_BASIC_테스트계정_인증성공() throws Exception {
                mvc.perform(get("/resource")
                                .with(httpBasic("root", "root")))
                .andExpect(content().string("resource"))
                .andExpect(status().isOk());
        }
        
        @Test
        void PasswordEncoder_테스트() {
                String password = "12345";
                assertThat(encoder.matches(password, encoder.encode(password))).isEqualTo(Boolean.TRUE);
        }
}

 

spring-security-01-httpbasic.zip
0.07MB

 

 

스프링 시큐리티 인 액션 | 로렌티우 스필카 - 교보문고

스프링 시큐리티 인 액션 | 모든 스프링 개발자에게 권장하는 스프링 시큐리티 필수 가이드!보안은 타협할 수 없는 중요한 요소다. 스프링 시큐리티로 안전하게 데이터를 전송하고 자격 증명을

product.kyobobook.co.kr

 

보안은 실제 어플리케이션의 목적인 기능적인 요소가 아닌 비기능적인 요소지만, 필수적인 비기능적 요소다.

보안은 어플리케이션 개발 처음부터 고려해야 하는 요소다.

 

비기능적 요소

필수 요소를 제외한 요소로, 군대로 예를 들면 전투 보직을 제외한 (),(),(),(),() 이라고 있다.

 

스프링 시큐리티 : 개념과 장점

스프링 시큐리티는 유연하다. 낮은 수준의 보안에서 높은 수준의 보안까지 적용이 가능하다.

낮은 수준에서 높은 수준으로 수록, 복잡도가 증가해 유지 관리가 어려워 진다.

 

보안이라 함은 무언가를 지키는 것이다. 예를 들어 금고는 안에 내용물이 '무언가', 앞에 잠금 장치가 보안일 것이다.

스프링 시큐리티에선 데이터가 '무언가' 스프링 시큐리티가 보안이다.

 

스프링 시큐리티는 데이터가 시스템으로 들어올 데이터를 가로채 검증을 한다.

 

소프트웨어 보안이란?

 유출되면 민감한 데이터들이 있다. 이메일 주소처럼 상대적으로 민감한 데이터부터 신용 카드처럼 유출되면 민감한 데이터가 있다. 이러한 데이터는 의도된 사용자 외에는 접근을 못하게 해야한다.

 

보안에는 네트워크 보안, OS 보안 모두는 포함하는 복잡한 주제이다.

스프링 시큐리티는 사용자와 가장 가까이 맏닿아 있는 어플리케이션 보안을 다룬다.

 

모놀리식과 마이크로서비스 아키텍처

모놀리식 아키텍처는 하나의 시스템으로 모든 책임을 구현한다.

마이크로 서비스 아키텍처는 책임별로 여러 시스템을 구현한다.

 

마이크로 서비스는 확장성 유연성이 좋지만, 모놀리식에 비해 신경쓸 많아진다.

보안으로만 한정해서 보면, 시스템마다 보안 설정을 해줘야 한다. , 관리 포인트가 늘어난다.

 

스프링 시큐리티는 크게 사용자를 식별하는 인증(Authentication) 인증된 사용자에 대한 알맞은 권한부여(Authorization) 단계로 이뤄진다.

 

 

 

보안이 중요한 이유는 무엇인가?

보안을 등한 해서 해킹을 당해 정보 유출이되면, 법적인 문제, 복구 비용, 브랜드 이미지 실추 등이 발생한다.

선제적으로 보안에 신경을 썼으면, 발생하지 않았을 비용이다.

미리 보안에 신경쓰는 비용이 복구 비용보다 적다.

 

애플리케이션의 일반적인 보안 취약성

일반적인 취약성 목록

  • 인증 취약성
  • 세션 고정
  • XSS(교차 사이트 스크립팅)
  • CSRF(사이트 요청 위조)
  • 주입
  • 기밀 데이터 노출
  • 메서드 접근 제어 부족
  • 알려진 취약성이 있는 종속성 이용

인증과 권한 부여의 취약성

인증은 직관적으로 이해가 쉽다. 권한 부여만 예를 들어보면 아래와 같다.

인증은 정상적으로 수행 구매 내역을 조회하고 있다.

 

임꺽정이 홍길동 구매 내역을 확인할 있다. 이는 인증은 성공했으나, 자기 자신으로 조회만 가능하게 권한 부여를 해야 했으나, 이를 설정하지 않을 발생하는 상황이다.

세션 고정

인증 세션 ID 부여하는데 고유한 세션ID 부여하지 않아 중복이 발생하거나 기존 세션 ID 재사용 가능할 발생하는 취약성이다.

XSS

교차 사이트 스크립팅은 사이트에 스크립트를 주입해 다른 사용자가 이를 실행하도록 하는 공격이다.

다른 말로 "클라리언트 코드 삽입 공격"이라고도 한다.

 

스크립트는 "<script>악의적인 코드</script>" 의미한다.

 

, 3자의 스크립트가 실행되는 것으로 자바스크립트로 있는 모든 것이 위험하다.

개인정보 탈취, DDos 참여 등…

 

<script>태그가 실행될 있는 곳이 위험한 곳이다. (이메일, 댓글 )

 

CSRF

사이트 요청 위조는 정상적인 사이트로 로그인 로그아웃 하지 않은채

악의적인 사이트로 접근했을 , 숨겨진 스크립트가 동작해 서버로 요청을 위조하는 것이다.

 

 

애플리케이션의 주입 취약성 이해

주입 공격은 다양하다. XSS 주입 취약성 하나다. 이외도 유명한 SQL 주입이 있다.

주입 공격은 비정상 코드 묶음을 주입해 시스템을 조작하는 유형의 취약성이다.

 

민감한 데이터 노출

개발자의 부주의로 발생한다.

  • 로그에 불필요한 정보를 노출
  • application.properties 비밀번호 같은 정보같은 민감한 정보를 입력해 사용
  • 400, 500 에러에 서버의 예외 스택을 응답으로 보냄
  • 로그인 실패 ID/PW 무엇이 틀렸는지 정확히 응답

 

메서드 접근 제어 부족

일반적인 Controller - Service - Repository 계층에서 주로 Controller 계층에만 접근 제어를 한다.

이는 향후 기능 추가 접근 제어의 헛점이 생길 있다. 따라서 계층에 권한을 부여해야 한다.

 

알려진 취약성이 있는 종속성 이용

스프링 시큐리티를 포함하여, 다른 프레임 워크나 라이브러리를 사용할 사용하는 버전에 알려진 취약성이 있는 경우를 말한다. 취약성이 있는 버전은 제거해야 한다.

 

다양한 아키텍처에 적용된 보안

다양한 아키텍처 마다 요구되는 사항을 준구하면서, 스프링 키큐리티를 알맞게 구성해야 한다.

높은 보안은 복잡도와 성능을 잡아먹고, 낮은 보안은 위험도를 높이게 된다.

상황에 맞는 알맞은 구성을 적용해야 한다.

 

일체형 보안 설계

프론트엔드-백엔드 분리된 아닌 어플리케이션을 말한다.

일체형 어플리케이션은 프론트엔드와 백엔드 개발이 묶여 있어 비교적 보안 설계가 쉽다.

 

백엔드-프론트엔드 분리 보안 설계

프론트는 뷰나 리엑트를 이용해 별도로 개발하는 형태를 말한다.

경우 프론트와 백엔드는 REST API 통신을 하게되는데, 이때 반드시 백엔드와 같은 출처의 프론트엔드가 아닐 있다. 따라서 보안 설계가 복잡해진다.

 

 

+ Recent posts