본문 바로가기

Backend Development/Spring boot

[Spring boot] Spring Security login 예제 (filter 없이 수동 Autentication)

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 --