기본 구성
•
인증 필터는 인증 요청을 인증 관리자에게 위임하고 응답을 바탕으로 보안 컨텍스트를 구성
•
인증 관리자는 인증 공급자를 이용해 인증을 처리
•
인증 공급자는 인증 논리를 구현
•
인증 공급자는 사용자 관리 책임을 구현하는 사용자 세부 정보 서비스를 인증 논리에 이용
•
인증 공급자는 암호 관리를 구현하는 암호 인코더를 인증 논리에 이용
•
보안 컨텍스트는 인증 프로세스 후 인증 데이터를 유지
자동으로 구성되는 다음 빈에 관해 알아보자.
•
UserDetailsService
•
PasswordEncoder
UserDetailsService
사용자 세부 정보는 스프링 시큐리티로 UserDetailsService 계약을 구현하는 객체가 관리한다.
스프링 부트가 제공하는 기본 구현은 애플리케이션의 내부 메모리에 기본 자격 증명을 등록하는 일만 한다.
이 기본 자격 증명에서 사용자 이름은 'user' 이고, 기본 암호는 UUID 형식이며, 암호는 스프링 컨텍스트가 로드될 때 자동으로 생성된다.
기본 구현은 자격 증명을 애플리케이션에 보관하지 않고 메모리에 보관한다. 따라서 실제 운영 시엔 적용하기 위험하다.
PasswordEncoder
PasswordEncoder 는 암호를 인코딩하고, 암호가 기존 인코딩과 일치하는지 확인하는 역할을 한다.
UserDetailsService 처럼 PasswordEncoder 도 HTTP Basic 인증 흐름이 꼭 필요하다.
만일 UserDetailsService 의 기본 구현을 대체할때는 PasswordEncoder 도 지정해야 한다.
HTTP Basic 인증은 클라이언트가 헤더값에 접두사 Basic 을 붙이고 그 뒤에 콜론 (:) 으로 구분된 사용자 이름과 암호가 포함된 문자열을 Base64 인코딩하여 붙인다.
AuthenticationProvider
AuthenticationProvider 는 인증 논리를 정의하고, 사용자와 암호의 관리를 위임한다.
AuthenticationProvicer 의 기본 구현은 UserDetailsService, PasswordEncoder 에 제공된 기본 구현을 이용한다.
기본 구성 재정의
기본 구성 요소 재정의 하는 옵션을 알아야 맞춤형 구현을 연결하고, 애플리케이션에 맞게 보안을 적용할 수 있다.
UserDetailsService 재정의
스프링 시큐리티에 있는 InMemoryUserDetailsManager 구현을 이용하여 UserDetailsService를 재정의해볼 것이다.
InMemoryUserDetailsManager 구현은 메모리에 자격 증명을 저장하여 스프링 시큐리티가 요청을 인증할 때 이용할 수 있도록 해주며, 이를 통해 자체적으로 관리하는 자격 증명을 인증에 이용할 수 있다.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration // 클래스를 구성 클래스로 표시
public class ProjectConfig {
@Bean // 반환된 값을 스프링 컨텍스트에 빈으로 추가하도록 스프링에 지시
public UserDetailsService userDetailsService() {
var userDetailsService = new InMemoryUserDetailsManager(); // var 키워드로 구문을 간소화, 세부 정보를 감춤, 로컬 선언에만 이용
return userDetailsService;
}
}
Java
복사
클래스에 @Configuration 어노테이션을 지정했다. @Bean 어노테이션은 메서드가 반환한 인스턴스를 스프링 컨텍스트에 추가하도록 스프링에 지시한다. 이제 애플리케이션은 자동 구성된 기본 구성 요소 대신 컨텍스트에 추가한 UserDetailService 형식의 인스턴스를 이용한다. 하지만 동시에 두 가지 이유로 엔드포인트에 접근할 수 없다.
1.
사용자가 없다.
2.
PasswordEncoder가 없다.
이러한 두 문제를 해결하기 위해 다음과 같은 작업이 필요하다.
1.
자격 증명(사용자 이름 및 암호)이 있는 사용자를 하나 이상 만든다.
2.
사용자를 UserDetailsService에서 관리하도록 추가한다.
3.
주어진 암호를 UserDetailsService가 저장하고 관리하는 암호를 이용해 검증하는 PasswordEncoder 형식의 빈을 정의한다.
먼저 자격 증명 집합을 선언하고 추가한다. 일단은 미리 정의된 빌더를 이용해 UserDetails 형식의 객체를 만든다. 인스턴스를 만들 때는 사용자 이름과 암호, 그리고 하나 이상의 권한을 지정해야 한다.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration // 클래스를 구성 클래스로 표시
public class ProjectConfig {
@Bean // 반환된 값을 스프링 컨텍스트에 빈으로 추가하도록 스프링에 지시
public UserDetailsService userDetailsService() {
var userDetailsService = new InMemoryUserDetailsManager(); // var 키워드로 구문을 간소화, 세부 정보를 감춤, 로컬 선언에만 이용
var user = User.withUsername("yoon") // 주어진 사용자 이름, 암호, 권한 목록으로 사용자 생성
.password("1234")
.authorities("read")
.build();
userDetailsService.createUser(user); // UserDetailsService에서 관리하도록 사용자 추가
return userDetailsService;
}
}
Java
복사
지정이 완료되었으나, 아직은 엔드포인트를 호출할 수 없다. PasswordEncoder도 선언해야 한다.
지금은 아래와 같은 오류가 뜬다.
curl -w "%{http_code}" -u yoon:1234 http://localhost:8080/hello
Shell
복사
java.lang.IllegalArgumentException: You have entered a password with no PasswordEncoder. If that is your intent, it should be prefixed with `{noop}`.
Shell
복사
이를 해결하기 위해서는 PasswordEncoder 빈을 컨텍스트에 추가하면 된다.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
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.InMemoryUserDetailsManager;
@Configuration // 클래스를 구성 클래스로 표시
public class ProjectConfig {
@Bean // 반환된 값을 스프링 컨텍스트에 빈으로 추가하도록 스프링에 지시
public UserDetailsService userDetailsService() {
var userDetailsService = new InMemoryUserDetailsManager(); // var 키워드로 구문을 간소화, 세부 정보를 감춤, 로컬 선언에만 이용
var user = User.withUsername("yoon") // 주어진 사용자 이름, 암호, 권한 목록으로 사용자 생성
.password("1234")
.authorities("read")
.build();
userDetailsService.createUser(user); // UserDetailsService에서 관리하도록 사용자 추가
return userDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
Java
복사
엔드포인트 권한 부여 구성 재정의
스프링 시큐리티 기본 구성에서 모든 엔드포인트는 애플리케이션에서 관리하는 유효한 사용자가 있다고 가정하고, HTTP Basic 인증 방식으로 권한 부여 방법을 이용한다.
하지만 애플리케이션의 모든 엔드포인트를 보호할 필요는 없으며, 보안이 필요한 엔드포인트에 다른 권한 부여 규칙을 적용해야할 수도 있다.
우선 WebSecurityConfigurerAdapter 클래스를 확장해야한다. 이 클래스를 확장함으로써 configure(HttpSecurity http) 메서드를 재정의할 수 있다. but, Spring Security 6.0 버전 이후로 위의 클래스는 완전히 deprecated 되었다.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
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.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration // 클래스를 구성 클래스로 표시
public class ProjectConfig {
@Bean // 반환된 값을 스프링 컨텍스트에 빈으로 추가하도록 스프링에 지시
public UserDetailsService userDetailsService() {
var userDetailsService = new InMemoryUserDetailsManager(); // var 키워드로 구문을 간소화, 세부 정보를 감춤, 로컬 선언에만 이용
var user = User.withUsername("yoon") // 주어진 사용자 이름, 암호, 권한 목록으로 사용자 생성
.password("1234")
.authorities("read")
.build();
userDetailsService.createUser(user); // UserDetailsService에서 관리하도록 사용자 추가
return userDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 모든 요청에 인증이 필요
http.authorizeHttpRequests(authz -> authz.anyRequest().authenticated()).httpBasic(Customizer.withDefaults());
// 모든 요청에 인증없이 요청 가능
//http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()).httpBasic(Customizer.withDefaults());
return http.build();
}
}
Java
복사
HttpSecurity 매개 변수를 활용해 구성을 변경할 수 있다.
anyRequest()와 함께 authenticated()를 호출하면 모든 요청에 인증이 필요하게 되고, permitAll()를 호출하면, 인증 없이도 요청할 수 있다.
참고로, filterChain 메서드를 보면 책에서 @Override 해서 configure라는 메서드와는 다르게 썼는데, 이는 위에서 말했듯 WebSecurityConfigurerAdapter가 deprecated되면서 이에 대응하기 위해 알맞게 변경한 것이다.
참조 링크
•
모든 요청에 인증이 필요한 경우
# 인증 없이 요청하면 401
$ curl -w "%{http_code}" http://localhost:8080/hello
401%
# 인증하여 요청 시 성공
$ curl -w "%{http_code}" -u yoon:1234 http://localhost:8080/hello
hello!200%
# 비밀 번호가 틀리면 401
$ curl -w "%{http_code}" -u yoon:1234555 http://localhost:8080/hello
401%
Shell
복사
•
모든 요청에 인증없이 요청 가능한 경우
# 인증 없이 요청해도 성공
$ curl -w "%{http_code}" http://localhost:8080/hello
hello!200%
# 인증하여 요청해도 성공
$ curl -w "%{http_code}" -u yoon:1234 http://localhost:8080/hello
hello!200%
# 인증 시 비밀번호가 틀리면 401
$ curl -w "%{http_code}" -u yoon:1234555 http://localhost:8080/hello | jq
401
Shell
복사
AuthenticationProvider 구현 재정의
위에서 UserDetailsService 와 PasswordEncoder를 구성하는 방법에 대해 알아보았다. 이제 이 구성 요소들에 작업을 위임하는 AuthenticationProvider를 맞춤 구성하는 방법에 대해 알아보자.
위의 그림에서 AuthenticationProvider는 인증 논리를 구현하는데, AuthenticationManager 로부터 요청을 받으면 사용자를 찾는 작업은 UserDetailsService 에, 암호를 검증하는 작업은 PasswordEncoder 에 위임한다.
여기서는 바로 이 AuthenticationProvider 로 맞춤 구성 인증 논리를 구현하는 법에 대해 알아본다.
package com.example.demo.security;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import javax.naming.AuthenticationException;
import java.util.Arrays;
@Component // 구성 클래스 표시
public class CustomAuthenticationProvider implements AuthenticationProvider {
// 인증 논리 구현
@Override
public Authentication authenticate(Authentication authentication) {
// Principal 인터페이스의 getName() 메서드를 Authentication 에서 상속받음
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
// 이 조건은 일반적으로 UserDetailsService, PasswordEncoder 를 호출해서 사용자 이름과 암호를 테스트함
// 즉, 이 조건절이 UserDetailsService, PasswordEncoder 의 책임을 대체함
if ("yoon".equals(username) && "1234".equals(password)) {
return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
} else {
throw new AuthenticationCredentialsNotFoundException("Error in authentication");
}
}
// Authentication 형식의 구현 추가
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Java
복사
이제 위의 AuthenticationProvider 구현을 등록해서 사용해보자.
package com.example.demo.config;
import com.example.demo.security.CustomAuthenticationProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
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.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import java.security.Provider;
@Configuration // 클래스를 구성 클래스로 표시
public class ProjectConfig {
// 생성자 DI
private final AuthenticationConfiguration authenticationConfiguration;
private final CustomAuthenticationProvider customAuthenticationProvider;
public ProjectConfig(AuthenticationConfiguration authenticationConfiguration, CustomAuthenticationProvider customAuthenticationProvider) {
this.authenticationConfiguration = authenticationConfiguration;
this.customAuthenticationProvider = customAuthenticationProvider;
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
ProviderManager providerManager = (ProviderManager) authenticationConfiguration.getAuthenticationManager();
providerManager.getProviders().add(this.customAuthenticationProvider);
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 모든 요청에 인증이 필요
http.authorizeHttpRequests(authz -> authz.anyRequest().authenticated()).httpBasic(Customizer.withDefaults());
// 모든 요청에 인증없이 요청 가능
//http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()).httpBasic(Customizer.withDefaults());
return http.build();
}
}
Java
복사
중간의 authenticationManager 메서드를 간단히 설명하자면,
•
AuthenticationManager : 스프링 시큐리티에서 인증을 처리하는 핵심 구성 요소이다. 이 클래스는 다양한 인증 공급자(Authentication Provider)를 관리하며, 사용자 요청을 인증할 때 적절한 공급자를 선택한다.
•
ProviderManager : AuthenticationManager의 기본 구현체로, 여러 인증 공급자를 관리할 수 있다. customAuthenticationProvider를 ProviderManager에 추가하여, 커스텀 인증 로직을 스프링 시큐리티에 적용한다.
구성 클래스 분리
위에서는 Config 클래스를 하나로 작성했지만, 실제 운영 시에는 구성이 복잡하므로 하나의 클래스가 하나의 책임만 가지도록 책임에 따른 분리를 시키는 것이 좋다.
•
UserManagementConfig
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
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.InMemoryUserDetailsManager;
/**
* 사용자 관리와 암호 관리를 위한 구성 클래스
*/
@Configuration
public class UserManagementConfig {
@Bean // 반환된 값을 스프링 컨텍스트에 빈으로 추가
public UserDetailsService userDetailsService() {
var userDetailService = new InMemoryUserDetailsManager();
var user = User.withUsername("yoon")
.password("1234")
.authorities("read")
.build();
// UserDetailsService에서 관리하도록 사용자 추가
userDetailService.createUser(user);
return userDetailService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
Java
복사
•
WebAuthorizationConfig
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* 권한 부여 관리를 위한 구성 클래스
*/
@Configuration
public class WebAuthorizationConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authz -> authz.anyRequest().authenticated()).httpBasic(Customizer.withDefaults());
// 모든 요청에 인증 없이 요청 가능
//http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()).httpBasic(Customizer.withDefaults());
return http.build();
}
}
Java
복사