TIL - 0720

spring-security 인증 구현하기 반복

Form Login - generate JWT flow

  1. 로그인 페이지로 감
  2. 로그인 정보를 입력한 후 submit 버튼을 누름 - http://domain.com/login과 같은 정해진 URL 요청을 함
  3. 서블릿 필터에서 걸림 - FormLoginFilter, 필터의 대상으로 지정된 요청이라면 DespatcherServlet으로 가지않고 프로바이더로 가게끔
  4. 필터에서 입력한 정보를 가지고 인증 전 객체를 만듦 - PreAuthenticationToken
  5. 적당한 프로바이더를 찾아서 처리를 함 - AccountService와 협업
  6. 프로바이더에서 인증이 성공하면 인증 후 객체를 만듦 - PostAuthentication
  7. 필터로 다시 돌아와 성공, 실패 핸들러 메소드를 거치게 되어있음 - 각각을 핸들링 하는 객체가 담당
  8. 성공 핸들러는 jwt를 만들어서 response에 심음

Config 만들기

  • @EnableWebSecurity : 웹 보안을 활성화 시킴, WebSecurityConfigurer 구현해야함
  • 이후 필터를 어떻게 사용할지 등에 대한 내용이 들어감

필터 만들기

  • 시큐리티 기능에 있어서 컨트롤러의 역할 : 누가 처리해야할지, 성공, 실패 시 누가 처리해야할지 정하는 역할
    • AbstractAuthenticationProcessingFilter를 상속해야함 : 필수 구현 메소드를 구현하면 됨 - attempt, 필요 메소드를 오버라이딩 하면 됨 - success, failure
  • 서블릿 필터
    • 체이닝 가능함 : 여러 필터를 걸어둘 수 있음, 엔드포인트 별로 필터를 만들어두면 됨(FormLogin, SocialLogin)
    • 필터를 거치게 되어있음 : 선처리, 후처리를 할 수 있음 - 바디로 전달된(POST) 로그인 정보를 읽어서 인증 전 객체를 만든 다음 프로바이더 매니져에 요청해서 적당한 프로바이더를 찾고 진행
  • 특정 URL 요청했을 때 해당 필터가 동작하도록 Config에서 설정해줘야함
    • WebSecurityConfigurer의 메소드 중 config(HttpSecurity)를 오버라이딩해서 해당 필터를 설정해주면 됨
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    private FormLoginFilter formLoginFilter() {
        FormLoginFilter formLoginFilter = new FormLoginFilter("/login");
        return formLoginFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(formLoginFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}
  • 프로바이더 매니져에 요청할 때 어디서 오지? 라는 생각을 가지게 되는데 이미 스프링에서 구현해둔 것이 있음
    • 매니져를 가져오는 것은 이미 만들어놓은 필터(AbstractAuthenticationProcessionFilter)의 메소드로 요청하면 해당 매니져를 가져올 수 있음
    • 매니져에 인증 전 객체를 넘겨주면 해당 객체의 타입을 가지고 작업을 하는 프로바이더를 찾아줌
    • 아래 프로바이더에서 특정 클래스명과 함께 해당 클래스가 AbstractAuthenticationToken을 구현했는지 먼저 체크함(supports - isAssignableFrom는 자바가 기본적으로 제공하는 메소드로 해당 클래스가 특정 클래스를 상속하고 있는지를 체크하는 메소드), 구현했을 때 프로바이더가 구현한 인증 과정을 거치게됨(전달 받은 인증 전 객체를 가지고)
@Component
public class FormLoginProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PreAuthenticationToken preAuthenticationToken = (PreAuthenticationToken) authentication;
        Account account = accountService.findByUserId(preAuthenticationToken.getUserId());
        if (passwordEncoder.matches(preAuthenticationToken.getPassword(), account.getPassword())) {
            return PostAuthenticationToken.getPostToken(AccountContext.fromAccount(account));
        }
        throw new AccountInfoNotMatchException("인증 정보가 정확하지않습니다.");
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return PreAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

프로바이더 처리하기

  • 실질적인 인증 처리 역할을 맡은 객체
    • 인증 전 객체를 가지고 와서 인증 처리를 함
    • 지금 개발하고 있는 것은 FormLogin으로 DB에 저장되어있는 Account 정보와 일치하는지만 체크하면 되므로 AccountService를 통해서 AccountRepository에 접근함
    • 데이터베이스에 Account를 저장할 때 평문이 아니라 패스워드 인코더를 통해 저장하므로(특정 알고리즘 인코더를 생성한 후 Bean으로 수동 등록) 해당 패스워드 인코더를 가지고 와서 맞는지 체크까지 해야함
  • 처리 시 익셉션이 발생할 수 있음 : 로그인 하려는 정보가 아예 데이터베이스에 없거나, 비밀번호가 틀렸을 때
    • Authentication 익셉션을 상속받은 커스텀 익셉션을 만들면 됨 : 핸들링할 때는 AuthenticationException 하나만 해도 하위 예외가 발생하더라도 예외처리가 됨
@ControllerAdvice
public class SecurityControllerAdvice {
    private static final Logger log = LoggerFactory.getLogger(SecurityControllerAdvice.class);

    @ExceptionHandler(AuthenticationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public void handleAuthenticationException(AuthenticationException e) {
        log.error("AuthenticationException : {}", e.getMessage());
    }
}
  • 확인 후 성공했을 때는 인증 후 객체를 만들어줌 : Account를 가지고 만듦 - 인증 전 객체와의 차이점이라고 하면 어떤 권한을 가지는지 리스트로 가짐
  • 만든 프로바이더는 프로바이더 매니져에 포함시켜줘야함
    • 그래야 프로바이더 매니져가 해당 프로바이더를 찾지
@Configuration
public class SecurityConfig {

    @Autowired
    private FormLoginProvider formLoginProvider;
    
    protected void config(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(formLoginProvider);
    }
}

성공 핸들러 만들기, 실패 핸들러

  • 성공 핸들러는 인증이 성공하고, 이후 처리를 하는 역할 : jwt를 만들고 json 데이터를 만들어서 response에 심어주는 역할
    • ObjectMapper로 json으로 만드는 이유는 @RestController에 의한 처리이기때문에 body를 json으로 전달
  • 핸들러는 빈으로 관리
  • JWT를 만들어주는 디펜던시를 추가해야함
@Component
public class FormLoginSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private JwtFactory jwtFactory;

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        PostAuthenticationToken postAuthenticationToken = (PostAuthenticationToken) authentication;
        AccountContext accountContext = (AccountContext) postAuthenticationToken.getPrincipal();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(generateTokenDto(jwtFactory.generateToken(accountContext))));
    }

    private TokenDto generateTokenDto(String token) {
        return new TokenDto().setToken(token);
    }
}
  • 실패 핸들러도 똑같이 만들어주면 되는데, 어떤 응답을 해줄지는 기획대로

JWT 팩토리 만들기

  • 인증 확인, 부가 정보를 넣는 JWT를 만드는 역할
  • 특정 알고리즘과 비밀키를 가지고 싸인(시그니쳐를 만듦)을 함 : 비밀키는 하드코딩 해놓는게 아니라 외부에서 가져와서 쓰는 것으로 해야 안전함(코드 공유할 때 모르고 올려서 유출되면)
  • 빈으로 관리
public String generateToken(AccountContext accountContext) {
    JWT.create()
       .withIssuer(accountContext.getUserName);
       .withClaim(...)
       .sign(getAlgorithm());
}

private Algorithm getAlgorithm() {
    returnn Algorithm.HMAC256(SECRET_KEY.getBytes());
}