이제 클라이언트의 인증처리를 CAS 인증 서버에서 동작하도록 클라이언트 프로젝트에 작업해보자.
먼저, Spring Security의 CAS 모듈과 관련된 Bean을 설정한다. Spring Security와 Cas 인증 서버와 함께 동작하기 위함이다.
다음과 같이 설정 클래스를 생성하고 Bean을 추가해보자.
@Configuration
public class CasClientConfiguration {
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService("http://localhost:9000/login/cas");
serviceProperties.setSendRenew(false);
return serviceProperties;
}
@Bean
@Primary
public AuthenticationEntryPoint authenticationEntryPoint(ServiceProperties sP) {
CasAuthenticationEntryPoint entryPoint
= new CasAuthenticationEntryPoint();
entryPoint.setLoginUrl("https://localhost:8443/cas/login");
entryPoint.setServiceProperties(sP);
return entryPoint;
}
@Bean
public TicketValidator ticketValidator() {
return new Cas30ServiceTicketValidator(
"https://localhost:8443/cas");
}
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider provider = new CasAuthenticationProvider();
provider.setServiceProperties(serviceProperties());
provider.setTicketValidator(ticketValidator());
provider.setUserDetailsService(
s -> new User("kennen", "Mellon", true, true, true, true,
AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
provider.setKey("CAS_PROVIDER_LOCALHOST_9000");
return provider;
}
}
위에 추가한 Bean을 하나씩 설명하면
ServiceProperties
[현재 서비스에 대한 정보] ExceptionTranslationFilter 의 doFilter 동작 중에 AccessDeniedException 이 발생하면 ExceptionTranslationFilter 에 있는 AuthenticationEntryPoint(CasAuthenticationEntryPoint)의 commence 메소드가 실행 된다. 이때 Cas 인증 서버로 이동해 로그인을 시도하게 되는데 Service 정보를 함께 전달 한다. 인증 서버에서 인증 시도 후에 다시 서비스로 Redirect 할 때 service 의 URL 로 사용하기 위해서 이다.
authenticationEntryPoint
Spring Security 에서 httpBasic 설정을 추가하게 되면 Default 로 BasicAuthenticationFilter 가 추가된다. httpBasic().authenticationEntryPoint 를 이용해 AuthenticationEntryPoint 를 지정할 수 있는데, CasAuthenticationEntryPoint 를 등록하기 위해 해당 Bean 을 추가한다. 인증이 필요한 페이지에 접근 시 AccessDeniedException 이 발생하면 CasAuthenticationEntryPoint 의 commence 가 실행된다. Cas Server 의 로그인 페이지로 redirect 된다. (ServiceProperties Bean 참고)
casAuthenticationProvider
CasAuthenticationFilter 가 동작할 때 실제로 인증을 처리하는 주체이다. Filter 가 동작하는 중에 인증을 진행하게 된다. Bean 으로 등록한 ServiceProperties, ticketValidator, UserDetailsService 가 이 때 사용되며 Key 는 CasAuthenticationToken 을 만들 때 인자로 쓰인다.
ticketValidator
[티켓 유효성 체크] 실행 되기 전 순서 1. ExceptionTranslationFilter 진행 중에 AccessDeniedException 발생 2. CasAuthenticationEntryPoint.commence 실행 3. Cas 서버에서 인증 성공 후에 - CasAuthenticationFilter.doFilter 실행 4. CasAuthenticationFilter.attemptAuthentication 실행 5. AuthenticationManager.authenticate 실행 6. ProviderManager.authenticate 실행 7. AuthenticationProvider.authenticate 실행 8. CasAuthenticationProvider.authenticate 실행 9. 8번 실행 중에 authenticateNow 실행 10. authenticateNow 메소드 안에서 ticketValidator 사용 설명 Cas 서버에서 인증 성공을 하게 되면 CasAuthenticationFilter 로 들어오게 된다. 이 때 redirect 된 서비스가 인증요청을 보낸 서비스가 맞는지 확인하기 위해서 한번 더 체크를 한다. redirect 될 때 받은 ticket 과 현재 서비스의 이름을 다시 Cas 서버로 보내고 일치 하면 XML 형식으로 응답을 받게 된다. 이후에는 해당 xml 응답을 parsing 해서 UserDetails 로 변환한 뒤 CasAuthenticationToken 을 만들게 된다.
이제 특정 경로를 인증이 필요하도록 설정하고, 이 때 CasAuthenticationEntryPoint가 사용 되도록 Spring Security를 설정해보자.
WebSecurityConfigurerAdapter를 상속받는 Configuration을 만든다.
또한 CasAuthenticationFilter를 Bean으로 추가하고,
CasAuthenticationFilter에서 사용 할 AuthenticationManager 역시 Bean으로 추가한다.
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
ServiceProperties serviceProperties;
@Autowired
private AuthenticationProvider authenticationProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().regexMatchers("/secured.*", "/login").authenticated()
.and().authorizeRequests().antMatchers("/favicon.ico", "/static/**").permitAll()
.and().authorizeRequests().antMatchers("/admin/**").hasAuthority("ADMIN")
.and().authorizeRequests().antMatchers("/user/**").hasAuthority("USER")
.and().authorizeRequests().regexMatchers("/").permitAll()
.and().httpBasic().authenticationEntryPoint(authenticationEntryPoint);
}
@Bean
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
CasAuthenticationFilter filter = new CasAuthenticationFilter();
filter.setServiceProperties(serviceProperties); // Bean 위치 : CasConfig.java
filter.setAuthenticationManager(authenticationManager());
return filter;
}
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(Arrays.asList(authenticationProvider));
}
}
/secured, /login 에 대한 요청은 인증이 필요하도록 설정하고 '/' 에 대한 요청은 public 페이지로 설정했다.
Controller를 생성하고 테스트 해보자.
HomeController
@RestController
public class HomeController {
@GetMapping("/")
public String home(){
return "home";
}
}
SecuredController
@RestController
public class SecuredController {
@GetMapping("/secured")
public Authentication secured() {
Authentication auth = SecurityContextHolder.getContext()
.getAuthentication();
return auth;
}
}
먼저 HomeController에 요청을 보내보자.
그리고 /secured 또는 /login 요청을 보내보자.
URL을 확인해보자.
이전 요청과 포트 번호가 다른 것을 확인할 수 있다. 인증서버로 Redirect 된 것이다.
인증서버에서 인증 가능한 사용자 ID, Password를 입력하자.
그리고 이 ID,Password는 위에서 설정한 CasAuthenticationProvider 에서 UserDetailsService 로 등록했다.
로그인 하면 다음과 같은 결과를 확인할 수 있다.
인증이 필요한 요청인 /secured URL을 확인 할 수 있다.
또한 Controller에서 Return 했던 Authentication 내용을 확인할 수 있다.
로그인은 성공했다.
하지만 Single Sign On 은 다수의 Client Application이 한번의 로그인으로 인증처리 하는 것이 목적이기 때문에 Client Application을 하나 더 만들어서 테스트 해본다.
다음 글에서는 두 개의 Client가 한번의 로그인으로 인증처리가 되는지 확인해 보자.
어떻게 인증이 진행 되는지에 대한 자세한 설명은 잠시 미루고 Single Sign On 그리고 Single Sign Out 이 잘 동작하는지를 먼저 확인해보자.
casSecuredApp-19991.json 이라는 파일을 services 디렉토리에 생성한다.
그리고 json 파일에 다음의 내용을 추가한다.
{
"@class" : "org.apereo.cas.services.RegexRegisteredService",
"serviceId" : "^http://localhost:9000/login/cas",
"name" : "CAS Spring Secured App",
"description": "This is a Spring App that usses the CAS Server for it's authentication",
"id" : 19991,
"evaluationOrder" : 1
}
다음과 같은 구조가 된다.
json 파일 내용을 설명하면 다음과 같다.
serviceId : 인증 서버를 사용하고자 하는 클라이언트에 대한 정규식 URL 패턴을 정의한다. 이 글에서는 localhost:9000 로 된 클라이언트를 추가한다.
id : 실수로 중복해서 서비스가 등록되는 경우 충돌을 방지하기 위해 사용된다. json 파일의 이름은 serviceName-id.json 컨벤션을 따른다.
jdbc.getQuery().foreach이 부분을 보면 query 배열을 반복하면서 queryDatabaseAuthenticationHandler 메소드에 query 객체를 하나씩 전달해서 handler에 추가(add) 하고 있는것을 확인할 수 있다.
queryAndEncodeDatabaseAuthenticationHandler 를 확인해보자.
빨간색 박스로 표시한 부분이 핵심이다.
간단하게 말해서queryAndEncodeDatabaseAuthenticationHandler 메소드는 Query를 이용해서 Database에 있는 계정 정보를 조회해서 인증을 처리하는 Handler를 만들어서 리턴하는 메소드 이다.
이때전달받은 파라미터b(JdbcAuthenticationProperties.Query) 에 있는 정보들을 handler를 생성하고 설정할 때 사용하는데 우리가 application.properties 파일에 설정했던 값들임을 확인할 수 있다.
QueryDatabaseAuthenticationHandler내부에 대해서는 이후에 인증과정에서 자세하게 확인해보자.
지금은 어떻게 등록이 되었는지를 확인하는데 집중하도록 하자.
여기까지가 어떻게 JDBC 인증이 설정되는지에 대한 설명이기 때문에 이정도만 이해해도 진행하는데 큰 무리가 없을 것이다.
하지만 조금 더 알고 싶은 사람들을 위해 조금 더 설명하도록 하겠다.
JdbcAuthenticationEventExecutionPlanConfiguration 이 클래스는 어디서 사용하는 것일까??
위 이미지를 보면 JdbcAuthenticationEventExecutionPlanConfiguration 클래스는
AuthenticationEventExecutionPlanConfigurer 인터페이스를 구현한 것을 확인할 수 있다.
그리고 AuthenticationEventExecutionPlanConfigurer 인터페이스를 구현한 구현체들을 확인해면 다음과 같다.
그리고 이 구현체들을 사용하는 곳은 바로 CasCoreAuthenticationConfiguration 클래스이다.
빨간색 박스를 보면 List<AuthenticationEventExecutionPlanConfigurer> 를 파라미터로 받고 있는 것을 확인할 수 있고, @Autowired 애노테이션이 추가되었기 때문에 의존성 주입이 되어 있다는 것을 알 수 있다.
정말로 위에 설명했던 구현체들이 맞는지 Debug 모드로 실행하여 확인해보자.
Debug 모드로 실행 중에 breakpoint를 걸어두고 확인해보았다.
아래 이미지와 같은색 박스끼리 일치한다는 것을 알 수 있다.
List<AuthenticationEventExecutionPlanConfigurer> 자체를 Bean 으로 등록한 것은 아니고, 각각의 구현체를 Bean 으로 등록한 뒤에 List 형태로 주입한 것이다.
프로젝트에서 AuthenticationEventExecutionPlanConfigurer 인터페이스를 구현한 @Configuration 클래스를 생성하고 나서 spring.factories 에 추가하게 되면 해당 클래스 역시 List<AuthenticationEventExecutionPlanConfigurer> 에 포함되는 것을 확인할 수 있다.
이제 authenticationEventExecutionPlan 메소드를 자세히 살펴보자.
그리고 위에서 설명한 AuthenticationEventExecutionPlanConfigurer 인터페이스의 구현체들을 주입 받아서 각각의 구현체들을 DefaultAuthenticationEventExecutionPlan Bean 객체에 설정해주는 작업을 한다.
각각의 AuthenticationEventExecutionPlanConfigurer 구현체들은 DefaultAuthenticationEventExecutionPlan Bean 객체에 어떤 설정을 하는지 configureAuthenticationExecutionPlan 인터페이스의
configureAuthenticationExecutionPlan 메소드를 확인해보자.
쉬운 설명을 위해서 위에서 설명했던 JdbcAuthenticationEventExecutionPlanConfiguration 클래스를 확인해보자.
위에서 설명했던 것처럼 jdbcAuthenticationHandlers() 를 통해 설정된 jdbc 인증처리 handler를 가져온다.
그 다음 파라미터로 받은 DefaultAuthenticationEventExecutionPlan 클래스 인스턴스에handler와 PrincipalResolver를 등록해준다.
그리고 DefaultAuthenticationEventExecutionPlan 클래스를 확인해보자.
노란색 박스 부분을 보면 JdbcAuthenticationEventExecutionPlanConfiguration 에서 호출한 메소드 임을 확인할 수 있다.
그리고 빨간색 부분을 보면 authenticationHandlerPrincipalResolverMap 라는 멤버변수에 handler를 key로 principal을 value로 put 하는 것을 확인할 수 있다.
authenticationHandlerPrincipalResolverMap 변수는 인증처리를 할 때 사용된다.
사용되는 구체적인 코드는 인증 처리 과정에서 자세하게 설명하도록 하겠다.
지금까지 JDBC 인증방식 설정에 대해 알아 보았다.
다음 글에서는 Client Application을 생성하고 CAS 인증서버와 SSO 테스트를 해보겠다.
마지막으로 CasCoreAuthenticationConfiguration 에 대해 간략히 설명하고 마치겠다.
CasCoreAuthenticationConfiguration 설정 클래스는 cas-server-core-authentication 의존성이 추가되어 있어햐 하는데, 첫번째 글에서 언급했던 cas-server-support-json-service-registry 의존성을 추가하면 의존성 전이를 통해 프로젝트에 추가가 된다.
이어서 클라이언트 Application을 만들고 SSO 기본인증 테스트를 해도 되지만 그전에 해야할 일이 있다.
우선 로그인 화면에 정적 계정 사용에 대한 경고 문구가 뜨는 것이 보기 싫고, 무엇보다 사용자를 Database가 아닌 프로젝트에 넣어두는 것이 마음에 들지 않기 때문에 최소한 Database에서 계정을 찾아 인증하는 부분까지는 완성하고 나서 클라이언트 Application을 시작 하도록 하겠다.
인증 방식에는 여러가지가 있는데 나는 이중에서 Database Authentication의 Query를 사용하였다.
사용자 계정 Database
여러가지 Database가 있겠지만, 나는 mysql을 설치해서 사용했다.
PC에 mysql을 설치하고 아래 이미지처럼 사용자 table을 생성하고 테스트용 데이터를 저장하였다.
password 는 bCrypt로 암호화 하여 저장하였다.
Database에 계정이 생겼으니 이제 application.properties 파일에 설정 해주면 된다.
#cas.authn.accept.users=casuser::Mellon
cas.authn.jdbc.query[0].driverClass=com.mysql.cj.jdbc.Driver
cas.authn.jdbc.query[0].url=jdbc:mysql://localhost:3306/sso?autoReconnect=true&allowMultiQueries=true&serverTimezone=UTC
cas.authn.jdbc.query[0].sql=select id, name, password from sso.user where id = ?;
cas.authn.jdbc.query[0].user=root
cas.authn.jdbc.query[0].password=1234
cas.authn.jdbc.query[0].fieldPassword=password
cas.authn.jdbc.query[0].passwordEncoder.type=BCRYPT