개발시, 외부 SOAP 서비스 또는 REST 서비스를 이용해서 인증을 처리해줘야지 되는 경우가 자주 발생하게 됩니다. 특히, 기존의 인증 시스템을 가지고 있는 상태에서 신규 개발은 기존의 인증시스템을 그대로 사용해야지 될 때, 이런 경우가 자주 생기게 됩니다.
일단. 이와 같은 상황에서는 가장 널리 사용되고 있는 DaoAuthentication을 사용하지 못합니다. Application의 Database에는 사용자의 정보를 전혀 가지지 못하게 되니까요. 이때, 인증 process만을 외부에서 사용하고, 인증된 Process를 거친 후, Spring Security FIlter Chain을 통과하게 된다면 가장 좋은 방법이 될 것 같습니다.
Spring Security 인증 구성
Spring Security에서는 여러가지 인증방법을 제공하는데, 이를 지원하기 위해서는 AuthenticationManagerBuilder에 AuthenticationProvider를 구현한 객체를 설정해줘야지 됩니다.
Spring Security에서 일반적으로 인증을 구성하는 방식은 다음과 같습니다.
- AuthenticationProvider 구성
- AuthenticationManager 구성 후, Provider에 Bean Setting
- AuthenticationManagerBuilder에 설정된 AuthenticationProvider 설정
가장 기본적이라고 할 수 있는 DaoAuthenticationProvider를 이용한 설정은 다음과 같습니다.
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Override
@Bean(name = BeanIds.USER_DETAILS_SERVICE)
public UserDetailsService userDetailsServiceBean() throws Exception {
CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
userDetailsService.setUserService(context.getBean(UserService.class));
return userDetailsService;
}
RemoteAuthenticationProvider 의 구성
DaoAuthenticationProvider의 구성과 동일하게 코드는 다음과 같이 구성될 수 있습니다.
@Bean
public RemoteAuthenticationProvider remoteAuthenticationProvider() throws Exception {
RemoteAuthenticationProvider provider = new RemoteAuthenticationProvider();
provider.setRemoteAuthenticationManager(remoteAuthenticationManager());
return provider;
}
여기에서 중요한 것이, setRemoteAuthenticationManager method입니다. 이제 외부에서 인증을 하는 Client code가 구성되는 곳이 RemoteAuthenticationManager이기 때문입니다.
RemoteAuthenticationManager interface는 다음과 같이 구현되어 있습니다.
public interface RemoteAuthenticationManager {
Collection<? extends GrantedAuthority> attemptAuthentication(String username, String password) throws RemoteAuthenticationException;
}
매우 단순한 Interface입니다. 여기서 주의할 것이, username과 password는 모두 평문으로 들어오게 됩니다. DaoAuthenticationManager의 경우, Password를 따로 encoding을 할 수 있는 기능을 제공하지만 RemoteAuthenticationManager의 경우에는 plan text로 username과 password를 다뤄야지 됩니다. 하긴 이게 당연한것인지도 모릅니다. username과 password를 단순히 전달하는 역활을 하게 되는 것이 일반적이니까요.
개인적으로 만들어본 SOAP을 이용한 외부 서비스를 호출하는 RemoteAuthenticationManager입니다. RemoteAuthenticationManager를 통해 인증을 처리하고, 외부 SOAP API에 있는 사용자 정보를 Application DB에 옮기는 작업을 하도록 구성하였습니다. 그 이유는 UserDetailsService 때문입니다. UserDetailsService를 통해 username, password 이외의 사용자 정보를 활용할 수 있는 여지를 남겨주고 싶었습니다.
public class SsoAuthServiceImpl implements SsoAuthService, RemoteAuthenticationManager {
private static final QName SERVICE_NAME = new QName("http://ws.daesung.co.kr/", "SSOWebServiceForJava");
public static final String USERNAME_PASSWORD_IS_NOT_MATCHED = "username/password is not matched!";
public static final String ROLE_USER = "ROLE_USER";
@Getter
@Setter
private UserService userService;
@Getter
@Setter
private ApplicationContext context;
@Override
public SsoAuthResult login(String username, String password) throws IOException {
URL wsdlURL = SSOWebServiceForJava.WSDL_LOCATION;
SSOWebServiceForJava service = new SSOWebServiceForJava(wsdlURL, SERVICE_NAME);
SSOWebServiceForJavaSoap request = service.getSSOWebServiceForJavaSoap12();
ArrayOfString result = request.ssoLoginArray(username, password, "NS");
List<String> items = result.getString();
SsoAuthResult authResult = context.getBean(SsoAuthResult.class);
authResult.initialize(items);
return authResult;
}
@Override
public Collection<? extends GrantedAuthority> attemptAuthentication(String username, String password) throws RemoteAuthenticationException {
try {
SsoAuthResult authResult = login(username, password);
if (authResult.isLoginSuccess()) {
User user = userService.findByUsername(username);
if(user == null) {
user = userService.addNewUser(username, authResult.getName(), password, ROLE_USER);
} else {
user.setName(authResult.getName());
userService.updateUser(user);
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for(String role : user.getRoles()) {
authorities.add(new SimpleGrantedAuthority(role));
}
return authorities;
} else {
throw new RemoteAuthenticationException(USERNAME_PASSWORD_IS_NOT_MATCHED);
}
} catch (Exception e) {
throw new RemoteAuthenticationException(USERNAME_PASSWORD_IS_NOT_MATCHED);
}
// return null;
}
}
RemoteAuthenticationManager의 경우에는 attemptAuthentication method만을 갖는 interface입니다. 사용자의 username, password가 전달되기 때문에, 이를 이용해서 외부 API call을 해서 인증을 처리하는 것이 가능하게 됩니다.
이를 모두 반영시킨 Spring Security의 Configuration은 다음과 같습니다.
@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
@ImportResource(value = "classpath:cxf-servlet.xml")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
public static final String ADMIN_ROLE = "ADMIN";
@Autowired
private UserService userService;
@Autowired
private ApplicationContext context;
@Autowired
private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().authenticationEntryPoint(corsEntryPoint)
.and()
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
.antMatchers("/api/apiKey/**").authenticated()
.antMatchers("/api/auth/**").authenticated()
.and()
.addFilterAfter(digestAuthenticationFilter(), BasicAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(remoteAuthenticationProvider());
}
@Bean
public RemoteAuthenticationProvider remoteAuthenticationProvider() throws Exception {
RemoteAuthenticationProvider provider = new RemoteAuthenticationProvider();
provider.setRemoteAuthenticationManager(remoteAuthenticationManager());
return provider;
}
@Bean
public RemoteAuthenticationManager remoteAuthenticationManager() throws Exception {
SsoAuthServiceImpl ssoAuthService = new SsoAuthServiceImpl();
ssoAuthService.setUserService(userService);
ssoAuthService.setContext(context);
return ssoAuthService;
}
/**
* AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
* @return
* @throws Exception
*/
@Override
@Bean(name = BeanIds.USER_DETAILS_SERVICE)
public UserDetailsService userDetailsServiceBean() throws Exception {
CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
userDetailsService.setUserService(context.getBean(UserService.class));
return userDetailsService;
}
@Bean
public SsoAuthService ssoAuthService() {
return new SsoAuthServiceImpl();
}
}
UserDetailsService는 반드시 구현하는 것이 좋습니다. 그리고 외부 서비스를 이용해서 로그인을 진행하더라도, 기본적인 사용자 정보을 반드시 Application DB안에 넣는 것이 좋을 것 같습니다.