Spring Security 로 개발을 진행하다 보면 수많은 커스터마이징으로 진행하는 인증 시퀀스가 헷갈리는 경우가 많다. 간단한 설정으로 Spring Security 개념들을 파악해 보고자 한다.
소스 트리는 다음과 같다.
기본이 되는 Security config 모습이다. Username/password 인증 및 필터없이 login api에서 직접 authenticate를 함으로 써 최소한의 설정으로 인증 테스트를 해본다.
src/main/java/com/kindlove/security/example/login/config/SecurityConfig.java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {// @formatter:off
// 인증 진행 시 사용자 정보를 가져올 테스트 UserService
@Autowired
private SecurityUserService securityUserService;
// 실제로 Id/Password를 서버에 저장되어 있는 정보와 같은지 비교함
@Autowired
private SecurityDefaultAuthProvider idPwAuth;
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
// filter를 사용하지 않고 LoginController에서 직접 Authenticatte하기 위해 AuthenticationManager Bean을 수동 생성함
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/home").permitAll() //인증 상관없이 무조건 실행되는 Url
.antMatchers("/user/principal").authenticated() //인증없이 진입시 403 Forbidden 발생함
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.and()
.csrf().disable()
.formLogin().disable() //기본으로 제공하는 formLogin설정 사용안함
.httpBasic().disable() //기본으로 제공하는 httpBasic설정 사용안함
.exceptionHandling()
.authenticationEntryPoint(new BasicAuthenticationPoint()); //Auth관련 AccessDenied발생 시 처리함
http
.logout() //기본으로 제공하는 LogoutConfigurer를 사용함
.logoutUrl("/login/logout") //logout 진입 Entry
.logoutSuccessUrl("/user/principal"); //logout 성공 시 진입할 url
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(idPwAuth).userDetailsService(securityUserService).passwordEncoder(bCryptPasswordEncoder);
}
참고로 위 설정에서 sessionCreationPolicy는 다음의 종류가 있고 현재 session 기반으로 상태저장을 함으로 STATELESS로 설정하지 않았다. STATELESS로 설정하면 인증후에도 세션에 상태저장이 안되어서 인증통과가 안된다.
http
.sessionManagement()
.sessionCreationPolicy( SessionCreationPolicy.정책상수)
SessionCreationPolicy.ALWAYS - 스프링시큐리티가 항상 세션을 생성
SessionCreationPolicy.IF_REQUIRED - 스프링시큐리티가 필요시 생성(기본)
SessionCreationPolicy.NEVER - 스프링시큐리티가 생성하지않지만, 기존에 존재하면 사용
SessionCreationPolicy.STATELESS - 스프링시큐리티가 생성하지도않고 기존것을 사용하지도 않음
->JWT 같은토큰방식을 쓸때 사용하는 설정
출처: https://fenderist.tistory.com/342 [Devman]
src/main/java/com/kindlove/security/example/login/web/controllers/LoginController.java
@RestController
public class LoginController {
@Autowired
private AuthenticationManager authenticationManager;
@RequestMapping(value = "/login/login", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<?> authenticateUser(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(true);
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
"testId",
"testPassword"
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
return ResponseEntity.ok("OK");
}
}
src/main/java/com/kindlove/security/example/login/config/SecurityDefaultAuthProvider.java
@Component
public class SecurityDefaultAuthProvider extends AbstractUserDetailsAuthenticationProvider {
@Autowired
UserDetailsService userDetailService;
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
String presentedPassword = authentication.getCredentials().toString();
String sha256hex = DigestUtils.sha256Hex(presentedPassword);
if (!sha256hex.equals(userDetails.getPassword())) {
throw new BadCredentialsException("bad");
}
}
@Override
protected UserDetails retrieveUser(String userId, UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken) throws AuthenticationException {
UserDetails user = userDetailService.loadUserByUsername(userId);
if (user == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return user;
}
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
src/main/java/com/kindlove/security/example/login/web/controllers/UserRestController.java
@RestController
@RequestMapping("/user")
public class UserRestController {
@GetMapping("/principal")
public User getOidcUserPrincipal(@AuthenticationPrincipal User principal) {
return principal;
}
}
src/main/java/com/kindlove/security/example/login/web/controllers/HomeRestController.java
@RestController
public class HomeRestController {
@GetMapping("/home")
public String simpleHomepage() {
return "Welcome to this simple homepage!";
}
}
테스트
http://localhost:8080/home 을 진입해보면 permitAll 속성이므로 인증없이 무조건 Controller가 실행된다.
반면 위에서 살펴본대로 SecurityConfig에서 다른 url들은 Authentication을 거치도록 설정하였으므로 로그인 없이 url진입 시 엑세스 거부 화면이 출력 됨을 알 수 있다.
src/main/java/com/kindlove/security/example/login/config/BasicAuthenticationPoint.java
@Component
public class BasicAuthenticationPoint extends BasicAuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authEx) throws IOException {
response.addHeader("WWW-Authenticate", "Basic realm=" + getRealmName());
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
@Override
public void afterPropertiesSet() {
setRealmName("Chandana");
super.afterPropertiesSet();
}
}
CallStack을 살펴보면 아래와 같이 handleAccessDeniedException 에서 허가 되지 않은 url을 감지하고
BasicAuthenticationPoint을 불러서 403 에러 페이지를 출력하게 한다.
commence:18, BasicAuthenticationPoint (com.kindlove.security.example.login.config)
sendStartAuthentication:215, ExceptionTranslationFilter (org.springframework.security.web.access)
handleAccessDeniedException:193, ExceptionTranslationFilter (org.springframework.security.web.access)
handleSpringSecurityException:174, ExceptionTranslationFilter (org.springframework.security.web.access)
doFilter:143, ExceptionTranslationFilter (org.springframework.security.web.access)
doFilter:116, ExceptionTranslationFilter (org.springframework.security.web.access)
doFilter:336, FilterChainProxy$VirtualFilterChain (org.springframework.security.web)
로그인 절차
http://localhost:8080/login/login 으로 로그인 api를 호출해보자.
아래와 같이 login controller 내에서 authenticate 메소드를 호출하고
@RequestMapping(value = "/login/login", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<?> authenticateUser(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(true);
// 직접 AuthenticationManager의 authenticate 메소드를 호출한다.
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
"testId",
"testPassword"
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
return ResponseEntity.ok("OK");
}
SecurityDefaultAuthProvider에서는 등록한 SecurityUserService의 loadUserByUsername을 통해 인증하고자 하는 사용자 정보를 서버에서 가져온다. 이후 인증하려고 입력한 password와 여기서 가져온 서버에 저장되어 있는 암호화된 password와 같은지 비교함으로 authentication을 진행한다.
loadUserByUsername:22, SecurityUserService (com.kindlove.security.example.login.service)
retrieveUser:31, SecurityDefaultAuthProvider (com.kindlove.security.example.login.config)
authenticate:133, AbstractUserDetailsAuthenticationProvider (org.springframework.security.authentication.dao)
authenticate:182, ProviderManager (org.springframework.security.authentication)
authenticate:201, ProviderManager (org.springframework.security.authentication)
authenticate:510, WebSecurityConfigurerAdapter$AuthenticationManagerDelegator (org.springframework.security.config.annotation.web.configuration)
authenticateUser:29, LoginController (com.kindlove.security.example.login.web.controllers)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
doInvoke:205, InvocableHandlerMethod (org.springframework.web.method.support)
invokeForRequest:150, InvocableHandlerMethod (org.springframework.web.method.support)
invokeAndHandle:117, ServletInvocableHandlerMethod (org.springframework.web.servlet.mvc.method.annotation)
invokeHandlerMethod:895, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handleInternal:808, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
... 중략
doRun:1722, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1191, ThreadPoolExecutor (org.apache.tomcat.util.threads)
run:659, ThreadPoolExecutor$Worker (org.apache.tomcat.util.threads)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)
@Component
public class SecurityDefaultAuthProvider extends AbstractUserDetailsAuthenticationProvider {
@Autowired
UserDetailsService userDetailService;
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
String presentedPassword = authentication.getCredentials().toString();
String sha256hex = DigestUtils.sha256Hex(presentedPassword);
if (!sha256hex.equals(userDetails.getPassword())) {
throw new BadCredentialsException("bad");
}
}
additionalAuthenticationChecks:22, SecurityDefaultAuthProvider (com.kindlove.security.example.login.config)
authenticate:147, AbstractUserDetailsAuthenticationProvider (org.springframework.security.authentication.dao)
authenticate:182, ProviderManager (org.springframework.security.authentication)
authenticate:201, ProviderManager (org.springframework.security.authentication)
authenticate:510, WebSecurityConfigurerAdapter$AuthenticationManagerDelegator (org.springframework.security.config.annotation.web.configuration)
authenticateUser:29, LoginController (com.kindlove.security.example.login.web.controllers)
Authentication이 만료되면 SecurityContextHolder에 인증 완료 정보를 설정해준다.
@RequestMapping(value = "/login/login", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<?> authenticateUser(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(true);
... 중략
// 인증완료 후 SecurityContextHolder에 인증 완료 정보를 설정해준다.
SecurityContextHolder.getContext().setAuthentication(authentication);
return ResponseEntity.ok("OK");
}
로그인 이 정상적으로 되면 API의 응답으로 OK가 출력됨을 볼 수 있다.
인증 완료 후 http://localhost:8080/user/principal 을 실행하면 정상적으로 인증 정보를 가져옴을 볼 수 있다.
-- To be continued --
'Backend Development > Spring boot' 카테고리의 다른 글
[Spring boot] Spring boot test H2 DB 사용하기 (0) | 2022.07.08 |
---|---|
[Spring Boot] swagger ui HTML문서로 출력하기 (1) | 2022.06.22 |
[Spring boot] Spring Security saml2.0 예제 분석 (spring-security-saml2-core) (0) | 2022.05.12 |
[Spring boot] authenticationEntryPoint /auth (0) | 2022.05.12 |
[Spring boot] SAML2.0 SSO 간단 정리 (0) | 2022.05.10 |