Spring Security는 인증과 인가 그리고 일반적인 공격에 대한 보호에 대해 제공하는 프레임워크 입니다. 그래서 스프링 기반 어플리케이션을 보호하기 위한 표준이라고 생각하시면 될 것 같습니다. 일단 해당 기능들과 인증과 인가 그리고 일반적인 공격이 무엇인지에 대해 같이 알아보도록 하겠습니다.
먼저 Spring Security 설정에 대해 알아 보겠습니다. 일반적으로 Gradle을 사용하기 때문에 Gradle로 설명하겠습니다. 일단 아래와 같이 dependency를 추가해 줍니다.
dependencies {
compile "org.springframework.boot:spring-boot-starter-security"
}
만약 직접 버전을 정하고 싶으시면 아래와 같이 설정해 주시면 됩니다.
ext['spring-security.version']='6.0.1'
그리고 Gradle Repositories 설정을 해주셔야 하는데 모든 Spring Security 릴리즈 버전은 MavenCentral에 올라가기 때문에 아래와 같이 설정해 주도록 합시다.
repositories {
mavenCentral()
}
위와 같은 설정이 완료되면 Spring Security 설정은 완료가 됩니다.
일단 Authentication 인증은 컴퓨터 시스템 사용자의 신원과 같은 주장을 증명하는 행위입니다. 그래서 일반적으로 아이디와 비밀번호나 토큰을 통해 사용자의 신원을 증명하는 행위를 뜻합니다. Authorization 인가는 쉽게 말해 권한을 부여한다라고 생각하시면 될 것 같습니다. 인증을 통해 신원을 증명하는 행위는 결국에는 특정 리소스에 접근하기 위한 행위입니다. 특정 리소스에 대해 모든사람이 접근하면 안되기 때문에 리소스에 대한 권한이 필요한데 이러한 권한을 부여해주는 개념이라고 생각하시면 될 것 같습니다.
Spring Security는 인증에 대해서 포괄적인 지원을 해줍니다. 스프링 시큐리티는 사용자 인증을 위한 내장 지원을 제공합니다. Servlet 및 WebFlux 환경에서 모두 적용됩니다. 어떤 지원을 해주는지 한번 자세하게 알아보도록 하겠습니다. Spring Security에서는 인증을 위한 비밀번호를 안전하게 저장하기 위해 PasswordEncoder intreface를 제공하는데 해당 interface는 암호를 단방향으로 변환시켜 저장하도록 도와줍니다. 해당 기능이 생기게 된 배경은 원래 암호는 평문으로 저장을 해왔었는데 sql injection 공격으로 사용자의 이름 아이디 패스워드 등을 탈취당하게 되었을 때 비밀번호가 그대로 저장되어있기 때문에 엄청 치명적 이었습니다. 그래서 그때부터 단방향 해시 SHA-256를 이용해 비밀번호를 저장하도록 권장하게 되었습니다. 그래서 일반적으로 단방향 암호화를 사용하게 되었는데 단방향 암호화에 대한 유효성 검사는 리소스를 많이 잡아 먹게 되기 때문에 서버의 CPU에 따라 적절한 알고리즘(bcrypt, PBKDF2,scrypt,argon2)을 선택할 수 있도록 선택해야하고 이러한 알고리즘을 선택할 수 있는 기능을 Spring Security에서 제공하지만 성능을 높이기 위한 기능은 해결할 수 없기 때문에 일반적으로 사용자는 단기 자격 증명인 Token 방식이나 세션 방식을 선택하는 편이 좋습니다. 해당 방법들은 단방향 암호화 유효성 검증보다 훨씬 빠릅니다.
Spring Security 5.0 이전에는 기본 PasswordEncoder가 평문으로 된 비밀번호가 필요한 NoOpPasswordEncoder 였습니다. 아까 이야기 했던 문제 때문에 기본 PasswordEncoder는 이제 BCryptPasswordEncoder와 같이 단방향 해시를 적용하게 되는 Encoder로 예상할 수 있습니다. 하지만 이것은 3가지 현실적인 문제를 무시합니다.
DelegatingPasswordEncoder는 해당 문제들을 해결할 수 있습니다.
아래와 같이 DelegatingPasswordEncoder를 PasswordEncoderFactories를 통해 생성하실 수 있습니다.
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
만약 커스텀해서 사용하고 싶으시다면 아래와 같이 생성하실 수 있습니다.
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
해당 암호 인코더는 특정한 형식으로 인코딩 됩니다. 어떤 알고리즘을 사용했는지 알아야하기 때문에 다음과 같이 인코딩 됩니다. {id}encodedPassword 여기서 id는 알고리즘을 나타내고 encodedPassword는 해당 알고리즘으로 인코딩된 암호를 나타냅니다. 아래는 bcrypt 알고리즘을 이용해 인코딩된 결과를 나타냅니다.
{bcrypt}$2a$10$H6no.Q3gPWaQqSG1MlKvled/Z7iBHYuN.ZEHlOr4QdaV3ghMQRJfq
인코딩은 아래와 같은 코드로 인코딩 할 수 있습니다.
PasswordEncode를 이용하는 방법
String password = "password";
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String encodePassword = passwordEncoder.encode(password);
User(Demo)를 이용하는 방법
User user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("user")
.build();
해당 방법은 Demo용 이기 때문에 Product에서 사용하는건 적절하지 않습니다.
인코딩 한 암호에 대한 유효성 검사를 하기 위해 아래와 같이 작성해 줍니다.
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
passwordEncoder.matches(password, encodePassword);
passwordEncoder.mathces는 인자로 평문 암호와 인코딩된 암호를 넣어 해당 암호가 일치한지 유효성 검사를 하고 그 반환값으로 boolean값을 반환해주어서 암호가 일치한지 확인 할 수 있습니다.
기본적으로 다음과 같은 PasswordEncoder 구현체를 제공합니다.
자세한 내용은 아래 공식 사이트를 통해 알 수 있습니다.
https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
Spring Security는 기본적으로 DelegatingPasswordEncoder를 사용합니다 하지만 내가 만든 커스텀 PasswordEncoder를 사용하고 싶다면 아래와 같이 Spring Bean으로 등록해서 사용할 수 있습니다.
@Bean
public static PasswordEncoder passwordEncoder() {
return new CustomPasswordEncoder();
}
CSRF는 Cross-Site Request Forgery의 약자로 사이트간 요청 위조를 이야기합니다. 웹 어플리케이션 취약점 중 하나로 사용자가 자신의 의지와 무관하게 사용자가 로그인된 사이트를 공격자가 의도한 행동을 해서 특정 웹페이지를 보안에 취약하게 한다거나 수정 삭제 등의 작업을 하게 만드는 방법입니다. 하나 예시를 들자면 A라는 사이트의 사용자 개인 비밀번호 변경을 하는 url이 xxxx/eample/pwchange/user=admin&pwd=1234 라고 할때 이러한 링크를 사용자의 메일로 보내 사용자가 이 이메일 읽게 되면 해당 사용자의 비밀번호가 1234로 초기화하게 됩니다. 구체적인 예를 들어 설명하겠습니다. 은행 웹사이트에서 로그인한 사용자의 돈을 다른 은행 계좌로 이체 할 수 있는 아래와같은 Form을 제공한다고 가정해보겠습니다.
<form method="post"action="/transfer">
<input type="text" name="amount"/>
<input type="text"name="routingNumber"/>
<input type="text"name="account"/>
<input type="submit"value="Transfer"/>
</form>
해당 Form에 상응하는 Http 요청은 다음과 같습니다.
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded
amount=100.00&routingNumber=1234&account=9876
이제 은행 웹사이트에 인증한 척하고 로그아웃하지 않은 상태로 악성 웹사이트를 방문합니다. 악성 웹사이트에서는 아래와 같은 HTML 페이지가 포함 되어있습니다.
<form method="post"
action="https://bank.example.com/transfer">
<input type="hidden"
name="amount"
value="100.00"/>
<input type="hidden"
name="routingNumber"
value="evilsRoutingNumber"/>
<input type="hidden"
name="account"
value="evilsAccountNumber"/>
<input type="submit"
value="Win Money!"/>
</form>
아마 거의 모든 사용자가 돈을 따는 것을 좋아하기 때문에 제출 버튼을 클릭합니다. 그 과정에서 의도하지 않게 100달러를 악의적인 사용자에게 전송했습니다. 이는 악의적인 웹 사이트가 사용자의 쿠키를 볼 수 없지만 사용자의 은행과 관련된 쿠키는 여전히 브라우저에 남아있어 요청과 함께 전송되기 때문에 해당 문제가 발생합니다.
CSRF 공격이 가능한 이유는 피해자 웹사이트의 HTTP 요청과 악성 웹사이트의 요청이 정확히 동일하기 때문에 가능합니다. 이는 악의적인 웹사이트에서 오는 요청을 거부하고 은행 웹사이트에서 오는 요청만 허용할 방법이 없음을 의미합니다. CSRF 공격으로부터 보호하기 위해 악의적인 사이트가 제공할 수 없는 요청에 무언가가 있는지 확인해서 두 요청을 구별할 수 있어야 합니다. 해당 요청을 구분하는 방법에는 두가지 매커니즘이 있습니다.
CSRF 공격으로부터 보호하는 가장 우세한 방법은 동기화 토큰 패턴을 사용하는 것입니다. 해당 방법은 세션 쿠키 외에도 CSRF 토큰이라는 안전한 랜덤값이 HTTP 요청에 있는지 하는지 확인하는 것입니다. HTTP 요청이 보내지면 서버는 예상되는 CSRF 토큰을 찾아 HTTP 요청의 실제 CSRF 토큰과 비교해야 합니다. 만약 일치하지 않는다면 HTTP 요청은 반려됩니다. 이것의 핵심은 CSRF 토큰이 브라우저에 의해 자동으로 포함되지 않는 HTTP 요청의 일부여야 한다는 점 입니다. 예를 들어 HTTP 매개변수 또는 헤더에 실제 CSRF 토큰을 요구하면 CSRF 공경으로부터 보호됩니다. 쿠키는 브라우저에 의해 자동으로 포함시키기 때문에 실제 CSRF 토큰을 쿠키에 넣으면 동작하지 않습니다. 애플리케이션의 상태를 업데이트하는 각 HTTP 요청에 대해서만 CSRF 토큰을 요구하도록 설계 하기 위해 HTTP 멱등성을 잘 지켜야합니다. 아래와 같이 csrf토큰을 포함 시킬 수 있습니다.
<form method="post"
action="/transfer">
<input type="hidden"
name="_csrf"
value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
<input type="text"
name="amount"/>
<input type="text"
name="routingNumber"/>
<input type="hidden"
name="account"/>
<input type="submit"
value="Transfer"/>
</form>
이제 form 형식에 CSRF 토큰 값이 포함된 숨겨진 입력이 포함됩니다. 외부 사이트는 해당 토큰을 읽을 수 없습니다. 해당 요청은 다음과 같이 보내집니다.
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded
amount=100.00&routingNumber=1234&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
CSRF 공격을 막기 위해서는 SamSite 속성을 쿠키에 명시하는 것입니다. 서버는 쿠키를 설정할 때 SameSite 속성을 지정하여 쿠키가 외부 사이트에서 올 때 전송되지 않아야 함을 나타낼 수 있습니다. Spring Security에서는 직접 세션 쿠키를 관리하지 않기 때문에 해당 속성에 대한 지원을 하지 않습니다. Spring Session은 서블릿 기반 어플리케이션에서 SameSite 속성에 대한 지원을 제공합니다. HTTP 헤더에 다음과 같이 설정할 수 있습니다.
Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax
SameSite 속성의 유효한 값은 다음과 같습니다.
필터 인스턴스는 컨테이너가 시작되기 전에 등록되야 하기도 하고 Servlet은 스프링 빈을 이해하지 못하기 때문에 Bean으로 등록된 SecurityFilterChain을 필터에 등록하기 위해서는 Spring에서 지원해 주는DelegatingFilterProxy를 사용해서 해당 빈을 필터에 등록할 수 있습니다. 그래서 DelegatingFilterProxy는 ApplicationContext에서 Bean Filter를 찾은 다음 Bean Filter를 호출합니다. 그리고 FilterChainProxy라는 Spring Security에서 지원하는 기술이 있는데 이를 통해 많은 Filter를 등록할 수 있습니다. 그리고 Security Exceptilon을 핸들링 할 수 있는 방법이 있는데 ExceptilnTranslationFilter를 사용하면 여러 Security Exception을 http 응답으로 변환할 수 있습니다.