Spring Security 여러개 적용하고, Admin page에서 일관된 password로 로그인하기
웹 서비스를 운영하다보면 관리자가 일반유저의 계정으로 로그인이 필요한 경우가 있습니다.
예를 들면, A라는 사용자가 로그인했을 때, 로그인된 화면에 당연히 보여야될 메뉴나 데이터가 없는 경우, 관리자에게 문의를 합니다.
관리자는 A 사용자의 현황을 모니터링할 목적으로 A 사용자의 계정으로 로그인 합니다.
하지만, A 사용자의 비밀번호를 직접적으로 물어볼 수 없으니, 다른 방법으로 인증을 해야합니다.
이번 포스팅에선 이런 상황을 위해 일반유저와 관리자 로그인 페이지를 나누고, 관리자가 일반유저의 계정으로 임의의 password로 로그인할 수 있는 방법에 대해서 소개합니다.
편의를 위해 일반사용자는 User, 관리자는 Admin으로 표현하겠습니다.
User는 account/form-login을 통해,
Admin은 adminAccount/form-login을 통해 인증됩니다.
우선 공통으로 사용될 SpringSecurityConfig를 작성하고, UserLoginSecurityConfig와 AdminLoginSecurityConfig에서 SpringSecurityConfig를 상속받아 정의합니다.
UML로 표현하면 다음과 같습니다.
공통 SpringSecurityConfig를 다음과 같이 작성합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final ApplicationContext context;
private final CustomUserDetailService userDetailService;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return new CustomAccessDeniedHandler();
}
@Bean
public AuthenticationSuccessHandler customLoginSuccessHandler() {
return new CustomLoginSuccessHandler(
context.getBean(MemberService.class), context.getBean(MenuCommoncodeService.class),
context.getBean(LogLoginService.class), context.getBean(UserdataAuthService.class),
context.getBean(ProgramMngService.class)
);
}
@Bean
public AuthenticationFailureHandler customLoginFailureHandler() {
return new CustomLoginFailureHandler(context.getBean(MessageSource.class), context.getBean(LogLoginService.class));
}
@Bean
public CustomLogoutSuccessHandler customLogoutSuccessHandler() {
return new CustomLogoutSuccessHandler();
}
@Bean
public SpringSecurityDialect springSecurityDialect() {
return new SpringSecurityDialect();
}
}
작성된 SpringSecurityConfig 를 AdminLoginSecurityConfig에서 상속 받습니다.
여기서 주의할 점은 User 로그인이 아닌, Admin 로그인 같이 특이한 경우에 @Order 어노테이션에 순번을 1번으로 줍니다.
그래야 antmatcher 와일드인증을 User 로그인에 적용할 수 있습니다.
@Configuration
@Order(1)
@EnableWebSecurity
public class AdminLoginSecurityConfiguration extends SpringSecurityConfiguration {
public AdminLoginSecurityConfiguration(ApplicationContext context, CustomUserDetailService userDetailService) {
super(context, userDetailService);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.authenticationProvider(adminCustomAuthenticationProvider())
;
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/assets/**", "/css/**", "/favicon/**", "/fonts/**", "/images/**", "/js/**", "/lib/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors().disable()
.antMatcher("/adminAccount/**")
.authorizeRequests()
.antMatchers("/css/**", "/assets/**", "/favicon/**", "/fonts/**", "/images/**", "/js/**", "/lib/**", "/favicon.ico").permitAll()
.antMatchers("/adminAccount/**", "/member/**", "/api/member/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/adminAccount/form-login")
.loginProcessingUrl("/adminAccount/loginProcess")
.failureUrl("/adminAccount/error?error=true")
.defaultSuccessUrl("/index")
.successHandler(customLoginSuccessHandler())
.permitAll()
.and()
.rememberMe()
.key("rememberKey")
.tokenValiditySeconds(2419200)
.and()
.logout()
.logoutUrl("/adminAccount/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/adminAccount/logout"))
.logoutSuccessHandler(customLogoutSuccessHandler())
.deleteCookies("JSESSIONID")
.and()
.exceptionHandling().accessDeniedHandler(accessDeniedHandler())
;
http.sessionManagement()
.maximumSessions(1)
.expiredUrl("/adminAccount/newsign?error=false");
}
@Bean
public AdminCustomAuthenticationProvider adminCustomAuthenticationProvider() {
return new AdminCustomAuthenticationProvider(netsUserDetailsService());
}
private NetsUserDetailsService netsUserDetailsService() {
return getApplicationContext().getBean(NetsUserDetailsService.class);
}
}
UserLoginSecurityConfig 를 정의합니다.
역시 SpringSecurityConfig 를 상속받고, @Order 어노테이션을 2번으로 줍니다.
authorizeRequests() 메서드 이전에 antmatcher 메서드로 HttpSecurity 를 사용할 것을 명시합니다.
그렇지 않으면,
authorizeRequests() 때문에 adminAccount 로 접근이 제한됩니다.
@Configuration
@Order(2)
@EnableWebSecurity
public class UserLoginSecurityConfiguration extends SpringSecurityConfiguration {
private final CustomUserDetailService userDetailService;
public UserLoginSecurityConfiguration(ApplicationContext context, CustomUserDetailService userDetailService) {
super(context, userDetailService);
this.userDetailService = userDetailService;
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.authenticationProvider(netsAuthenticationProvider())
.userDetailsService(userDetailService).passwordEncoder(passwordEncoder())
;
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/assets/**", "/css/**", "/favicon/**", "/fonts/**", "/images/**", "/js/**", "/lib/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors().disable()
.antMatcher("/**")
.authorizeRequests()
.antMatchers("/SSOClient/**").permitAll()
.antMatchers("/css/**", "/assets/**", "/favicon/**", "/fonts/**", "/images/**", "/js/**", "/lib/**", "/favicon.ico").permitAll()
.antMatchers("/sso/**").permitAll()
.antMatchers( "/account/**","/member/**", "/api/member/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/account/login")
.loginProcessingUrl("/account/loginProcess")
.failureUrl("/account/error?error=true")
.defaultSuccessUrl("/index")
.successHandler(customLoginSuccessHandler())
.failureHandler(customLoginFailureHandler())
.permitAll()
.and()
.rememberMe()
.key("rememberKey")
.tokenValiditySeconds(2419200)
.and()
.logout()
.logoutUrl("/account/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/account/logout"))
.logoutSuccessHandler(customLogoutSuccessHandler())
.deleteCookies("JSESSIONID")
.and()
.exceptionHandling().accessDeniedHandler(accessDeniedHandler());
http.sessionManagement()
.maximumSessions(1)
.expiredUrl("/account/ssonewsign?error=false");
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
var authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
@Bean
public NetsAuthenticationProvider netsAuthenticationProvider() {
return new NetsAuthenticationProvider(netsUserDetailsService());
}
private NetsUserDetailsService netsUserDetailsService() {
return getApplicationContext().getBean(NetsUserDetailsService.class);
}
}
그리고 Admin 사용자의 경우, 어떤 사용자든 로그인이 가능하도록 인증을 낚아채 처리합니다.
인증을 처리하는 AuthenticationProvider를 상속받아 재정의 합니다.
로직을보면 입력된 사용자의 id가 존재하고, 비밀번호가 "password"로 입력된 경우,
인증이 통과되도록 작성되어 있습니다.
즉, 어느 사용자든 "password"로 비밀번호를 입력해서 조회가 가능한 것이죠.
@RequiredArgsConstructor
@Component
public class AdminCustomAuthenticationProvider implements AuthenticationProvider {
private final NetsUserDetailsService netsUserDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (authentication == null) {
throw new InternalAuthenticationServiceException("Authentication is null");
}
String userName = authentication.getName();
if (authentication.getCredentials() == null) {
throw new AuthenticationCredentialsNotFoundException("Credentials is null");
}
String password = authentication.getCredentials().toString();
UserDetails loadedUser = netsUserDetailsService.loadUserByUsername(userName);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} /* checker */
if (!loadedUser.isAccountNonLocked()) {
throw new LockedException("User account is locked");
}
if (!loadedUser.isEnabled()) {
throw new DisabledException("User is disabled");
}
if (!loadedUser.isAccountNonExpired()) {
throw new AccountExpiredException("User account has expired");
}
if(!password.equals("password")){
throw new BadCredentialsException("Admin password is invalidate!");
}
/* checker */
if (!loadedUser.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("User credentials have expired");
} /* 인증 완료 */
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(loadedUser, authentication.getCredentials(), loadedUser.getAuthorities());
result.setDetails(authentication.getDetails());
return result;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
다음은 Admin 로그인 Controller를 정의합니다.
@Controller
@RequiredArgsConstructor
@RequestMapping("/adminAccount")
public class AdminLoginController {
private final MemberService memberService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@GetMapping("/form-login")
public String FormLoginPageA(HttpServletRequest request, Model model) {
return "account/admin-login";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
}
return "redirect:/form-login?logout";
}
@GetMapping("/newsign")
public String newsign(Model model, HttpSession session, HttpServletRequest request) {
return "account/newsign";
}
@GetMapping("/ssonewsign")
public String ssonewsign(Model model, HttpSession session, HttpServletRequest request) {
return "account/ssonewsign";
}
@GetMapping("/error")
public String error(HttpServletRequest request, Model model) {
model.addAttribute("error", request.getSession().getAttribute("errMsg"));
return "account/ssonewsign";
}
@GetMapping("/sessionOut")
public String sessionOut() {
return "account/logout";
}
}
아래는 User 로그인 Controller입니다.
@Controller
@RequiredArgsConstructor
@RequestMapping("/account")
public class LoginController {
private final MemberService memberService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Value("${xs.sso.permission-url}")
private String ssoPermissionUrl;
@GetMapping("/form-login")
public String FormLoginPage(HttpServletRequest request) {
//request.getSession().removeAttribute("errMsg");
return "account/form-login";
}
@PostMapping("/login")
public String loginError(Model model) {
model.addAttribute("errorCheck", true);
return "account/login";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
}
return "redirect:/form-login?logout";
}
@GetMapping("/error")
public String error(HttpServletRequest request, Model model) {
model.addAttribute("error", request.getSession().getAttribute("errMsg"));
//return "account/error";
return "account/ssonewsign";
}
@GetMapping("/permission")
public String permission(HttpServletRequest request, Model model) {
model.addAttribute("error", request.getSession().getAttribute("errMsg"));
model.addAttribute("permissionUrl", this.ssoPermissionUrl);
return "account/permission";
}
}
사실, 이처럼 특정 url을 Admin 사용자로 해두고 사용자를 모의해보는 방식은 권장하는 방식이 아닙니다.
url과 Admin 비밀번호가 노출되면, 어떤 사용자든 로그인을 할 수 있기 때문이죠.
그럼에도 Custom 인증은 다른 로그인방식에서도 많이 쓰일 수 있는 기술이기 때문에 정리해 보았습니다.
개발하면서 참고했던 사이트들입니다.
https://www.yawintutor.com/multiple-login-pages-using-spring-boot-security/
Multiple Login Pages using Spring Boot Security – user, admin login pages – Yawin Tutor
In real-time applications, we needed to have different login pages to be accessed within the same application. One for the regular consumer and the other for the administrative functions. In this post, we’ll see how to create two login pages,
www.yawintutor.com
https://velog.io/@jayjay28/2019-09-04-1109-%EC%9E%91%EC%84%B1%EB%90%A8
Spring Security 스프링 시큐리티
스프링 시큐리티에 대해 간단하게 개념 정리를 하고 단순 시큐리티를 적용해봅니다. 스프링 시큐리티 대략적인 기능 사용자 권한에 따른 URI 접근 제어 DB와 연동 하는 Local strategy 로그인 쿠키를
velog.io