본문 바로가기
스프링 부트

[스프링] UserDetailsService를 여러 개 구현하고 잘 사용하기

by illlilillil 2022. 8. 25.

그동안의 프로젝트들은 UserDetails를 하나만 구현했었습니다.

 

최근 진행한 프로젝트에서 관리자와 작업자 간 도메인이 명확히 달랐고, 그에 따른 인증과 인가 과정이 필요했습니다.

이런 경우 보통 관리자 앱으로 따로 만드는 것으로 알고 있지만 단일 프로젝트였기에 jwt 토큰 발행 시 권한을 다르게 부여하고,

UserDetailsService 인터페이스의 loadUserByUsername 구현할때 두 개의 서비스를 두고 구현했습니다.

@Override
  public CustomDetails loadUserByUsername(String adminId) {
    Admin admin = adminRepository.findById(Long.valueOf(adminId))
        .orElseThrow(() -> new IllegalArgumentException("찾을 수 없습니다."));
    return CustomDetails.builder().id(admin.getId())
        .authorities(List.of(admin.getAuthority().name())).build();
  }
@Override
public CustomDetails loadUserByUsername(String workerId) {
  Worker worker = workerRepository.findById(Long.valueOf(workerId))
      .orElseThrow(() -> new IllegalArgumentException("찾을 수 없습니다."));
  return CustomDetails.builder().id(worker.getId())
      .authorities(List.of(worker.getAuthority().name())).build();
}

이렇게 두 개의 userDetailService를 구현할 경우 securityConfig에 두 가지 서비스를 등록해야 합니다.

@Order(Ordered.HIGHEST_PRECEDENCE)를 꼭 한 곳에 입력 시켜주어야 하는데요.

스프링의 입장에선 무엇을 먼저 등록시킬지 알 수 없기에 꼭 써넣어 줍니다.

@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Order(Ordered.HIGHEST_PRECEDENCE)
public class WorkerSecurityConfig extends WebSecurityConfigurerAdapter {

  private final WorkerDetailService workerDetailService;
  
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic().disable()
        .csrf().disable() 
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
            UsernamePasswordAuthenticationFilter.class);

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(workerDetailService);
  }
}
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdminSecurityConfig extends WebSecurityConfigurerAdapter {

  private final AdminDetailService adminDetailService;
  
  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(adminDetailService);
  }
}

 

JWT 토큰 발행을 통해 로그인을 하고 인증 과정을 거칠 경우 등록된 userDetails가 두 개이기 때문에 권한을 읽어 그에 따른 처리를 해주어야 합니다. 

 

토큰 발행 - roles에 ROLE_ADMIN, ROLE_WORKER라는 정보가 담기게 됩니다.

public String createToken(String userPk, List<String> roles) {
    Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
    claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
    Date now = new Date();
    return Jwts.builder().setClaims(claims).setIssuedAt(now)
        .setExpiration(new Date(now.getTime() + tokenValidTime))
        .signWith(SignatureAlgorithm.HS256, secretKey).compact();
  }

 

토큰 읽기 - 발행된 토큰에서 roles로 등록된 데이터를 찾아야 합니다.

public List<String> getRoles(String token) {
    return (List<String>) Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
        .get("roles");
  }

찾은 권한에 따라 권한에 맞는 loadUserByUsername을 통해 UserDetails를 불러옵니다.

이후 UsernamePasswordAuthenticationToken을 발급합니다.

new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());

SecurityContext에 필터에서 값을 저장하도록 로직을 구성하면 됩니다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

  private final JwtTokenProvider jwtTokenProvider;

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
    // 유효한 토큰인지 확인합니다.
    if (token != null && jwtTokenProvider.validateToken(token)) {
      // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
      Authentication authentication = jwtTokenProvider.getAuthentication(token);
      // SecurityContext 에 Authentication 객체를 저장합니다.
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    chain.doFilter(request, response);
  }
}

 


여기서부턴 삽질 기록입니다.

userDeatils 인터페이스를 구현한 CustomDetails를 처음엔 두 가지를 만들었습니다.

CustomWorker와 CustomAdmin을 구현하고 loadUserByusername에도 다른 객체를 반환시켜주었습니다.

두 객체의 사용에 대한 인증은 userdetailservice를 통해 잘 되었습니다.

그러나 아래 코드와 같은 인가에 대한 부분에서 예외가 간헐적인 예외가 발생했습니다.

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/attendance")
  public ResponseEntity<List<WorkerAttendanceResponse>> checkAttendance(
      @CurrentUser CustomAdminDetails admin)

@CurrentUser 어노테이션을 통해 jwt에 의해 securityContext에 저장된 토큰을 가져오는 로직이었습니다.

어노테이션의 객체가 하나가 아니라 CustomAdminDetails, CustomWorkerDetails 였기에 생기는 문제가 있었습니다.

 

아래는 어노테이션을 통해 인증 객체를 가져오는 역할을 합니다.

그러나 프로젝트에서 등록한 userDetailSerivce가 2개였기에 CustomAdminDetails를 불러와야할 부분에서 CustomWorkerDetails를 불러오는 문제가 있었습니다. config를 통해 등록한 userDetailService에서 worker를 우선순위로 두었기에 admin 측에서 worker쪽의 객체를 불러와 타입이 맞지 않는 에러를 발생 시켰습니다.

securityContext에 객체를 넣을땐 jwt의 유효성만을 검증하기에 admin인지 worker인지 알 수가 없어서 생기는 문제입니다.

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

  @Override
  public boolean supportsParameter(MethodParameter parameter) {
    return parameter.hasParameterAnnotation(CurrentUser.class);
  }

  @Override
  public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (!(authentication instanceof AnonymousAuthenticationToken)) {
      return authentication.getPrincipal();
    } else {
      return null;
    }
  }
}

문제 해결을 위해 loadUserByusername의 반환 객체를 하나로 묶어 필터에서도 혼선이 없고, 인증 객체를 불러오는 어노테이션에서도 정확한 객체를 불러와 해결했습니다.

댓글