Spring/Spring Security

Spring Security > 사용자 테이블만 사용하는 애플리케이션에 체계적인 역할 기능을 추가해보자

Krevis 2024. 8. 2. 08:26

현 상태

user 테이블이 있다

 

권한 칼럼도 가지고 있다

 

사용자의 접근 권한 확인을 user 테이블에서 사용자 정보를 조회 후 권한 칼럼을 기준으로 관리자인지 아닌지를 코드에서 분기

원하는 바

user_authority 테이블을 만들어 사용자와 권한의 관계를 1:N으로 만들어 한 사용자가 여러 권한을 가지게 할 수도 있겠지만, 여기서는 간단히 세부적인 권한이 아닌 역할로 관리하며 1:1 관계를 유지하고, user 테이블을 그대로 사용하기로 한다

 

 

사용자 역할 클래스 생성

@RequiredArgsConstructor
@Getter
public enum UserRole {

    ADMIN(FullName.ADMIN),

    SUPER_ADMIN(FullName.SUPER_ADMIN);

    private final String fullName;

    public static class FullName {

        public static final String ADMIN = "ROLE_ADMIN";
        public static final String SUPER_ADMIN = "ROLE_SUPER_ADMIN";
    }
}

현재 사용자는 관리자와, 최고 관리자로 나뉠 수 있다

 

각 열거형 상수는 ROLE_ 접두사가 붙은 역할명을 가진 fullName 필드를 가진다

 

 

사용자 클래스 변경

User 클래스는 UserRole 타입의 role 필드를 가지게 한다

 

 

UserDetailsService 인터페이스 구현

이 인터페이스는 아래 추상 메서드 하나만을 정의한다

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

 

사용자명으로 DB 등에서 사용자를 조회 후 반환하는 역할을 한다

 

반환 타입은 UserDetails 인터페이스를 구현한 객체가 된다

 

여기서는 UserDetails 인터페이스를 구현한 org.springframework.security.core.userdetails.User 클래스를 상속받은 LoginUser라는 클래스를 만들고 애플리케이션에서 사용하는 User 객체를 포함하고 있도록 하였다

@Getter
public class LoginUser extends org.springframework.security.core.userdetails.User {

    private final User user;

    public LoginUser(User user) {

        super(user.getUsername(), user.getPassword(), AuthorityUtils.createAuthorityList(user.getRole().getFullName()));
        this.user = user;
    }
}
AuthorityUtils.createAuthorityList 메서드는 String 타입의 권한들을 스프링 시큐리티가 정의한 사용자 권한 타입인 List<GrantedAuthority>로 만들어준다

 

이제 UserDetailsService 인터페이스를 구현한 LoginUserDetailsService 클래스를 만든다

@Service
@RequiredArgsConstructor
public class LoginUserDetailsService implements UserDetailsService {

    private final UserRepository userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Optional<User> maybeUser = userRepo.findByUsername(username);
        if (!maybeUser.isPresent()) {
            throw new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username);
        }

        return new LoginUserDetails(maybeUser.get());
    }
}

 

 

이제 적당한 방식으로 계정 정보를 DB에 저장하도록 한 뒤 사용자가 로그인을 성공하면 역할 기반으로 보안 처리를 할 수 있게 된다

 

 

웹 요청 경로별 접근 권한 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    ..
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            ..
            .antMatchers("/login").permitAll()
            .antMatchers("/a/**).access("hasAnyRole('ADMIN', 'SUPER_ADMIN'")
            .antMatchers("/b/**).access("hasRole('SUPER_ADMIN'");
        
        http.exceptionHandling().accessDeniedPage("/login");
        ..
        
    }
    ..
}
  • /a로 시작하는 경로 접근은 관리자 또는 최고 관리자 모두 접근이 가능하다
  • /b로 시작하는 경로 접근은 최고 관리자만 접근 가능하다
  • 관리자가 /b로 시작하는 경로에 접근하면 권한이 없기 때문에 /login 경로로 리디렉션한다

 

 

사용자 역할에 따른 UI 처리

JSP나 타임리프의 경우 스프링 시큐리티 태그를 제공하여 편리하게 뷰 템플릿에서 사용 가능하다

JSP의 태그 라이브러리

..
<%@ taglib uri="http://www.springframework.org/security/tags" prefix="sec" %>

..
<sec:authorize access="hasAnyRole('ADMIN', 'SUPER_ADMIN')">
  <p>관리자입니다</p>
</sec:authorize>

<sec:authorize access="hasRole('SUPER_ADMIN')">
  <p>최고 관리자입니다</p>
</sec:authorize>

<sec:authorize access="!isAnonymous()">
  <p>관리자입니다</p>
</sec:authorize>
..

타임리프의 방언 예

..
<div sec:authorize="hasAnyRole('ADMIN', 'SUPER_ADMIN')">
  <p>관리자입니다</p>
</div>

<div sec:authorize="hasRole('SUPER_ADMIN')">
  <p>최고 관리자입니다</p>
</div>

<div sec:authorize="!isAnonymous()">
  <p>관리자입니다</p>
</div>
..

Handlebars의 예

하지만 현재 애플리케이션에서는 Handlebars를 사용 중인데 구글링해보니 Handlebars용 스프링 시큐리티 태그 기능은 없어 보인다. 유일하게 찾은 곳이 자바지기님의 글이어서 코드를 참고했다

 

HandlebarsSpringSecurityHelper.java

/**
 * .hbs 파일에서 JSP, Thymeleaf에서 사용하던 스프링 시큐리티 태그 기능을 유사하게 사용하도록 하기 위한 Handlebars helper
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class HandlebarsSpringSecurityHelper implements Helper<Object> {

    private final ApplicationContext applicationContext;

    @Override
    public Object apply(Object context, Options options) throws IOException {
        log.info("context: {}, options: {}", context, options);
        // context: hasAnyRole('ADMIN', 'SUPER_ADMIN'), options: com.github.jknack.handlebars.Options@2e40f4ed

        Buffer buffer = options.buffer(); // security 태그 나오기 전까지의 HTML

        if (canApply(context.toString())) {
            buffer.append(options.fn());
        } else {
            buffer.append(options.inverse());
        }

        return buffer;
    }

    /**
     * @param securitySpel SpEL(Spring Expression Language)
     */
    private boolean canApply(String securitySpel) {

        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            return false;
        }

        SecurityExpressionHandler<FilterInvocation> handler = getSpringExpressionHandler();

        Expression expression = handler.getExpressionParser().parseExpression(securitySpel);

        return ExpressionUtils.evaluateAsBoolean(expression, createExpressionEvaluationContext(handler));
    }

    private SecurityExpressionHandler<FilterInvocation> getSpringExpressionHandler() {
        Map<String, SecurityExpressionHandler> handlerMap = applicationContext.getBeansOfType(SecurityExpressionHandler.class);

        // DefaultWebSecurityExpressionHandler
        for (SecurityExpressionHandler handler : handlerMap.values()) {
            if (FilterInvocation.class == GenericTypeResolver.resolveTypeArgument(handler.getClass(), SecurityExpressionHandler.class)) {
                return handler;
            }
        }

        throw new IllegalStateException("보안 처리 중 오류가 발생했습니다. 웹사이트 담당자에게 문의해주세요");
    }

    private EvaluationContext createExpressionEvaluationContext(SecurityExpressionHandler<FilterInvocation> handler) {

        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        FilterInvocation filterInvocation = new FilterInvocation(requestAttributes.getRequest(), requestAttributes.getResponse(),
                                                                 (servletRequest, servletResponse) -> {
                                                                     throw new UnsupportedOperationException();
                                                                 });

        return handler.createEvaluationContext(SecurityContextHolder.getContext().getAuthentication(), filterInvocation);
    }
}

 

WebConfig.java

@Configuration
@RequiredArgsConstructor
public class WebConfig extends WebMvcConfigurerAdapter {

    private final HandlebarsViewResolver handlebarsViewResolver;

    private final HandlebarsSpringSecurityHelper handlebarsSpringSecurityHelper;

    ..

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {

        handlebarsViewResolver.registerHelper("sec", handlebarsSpringSecurityHelper);
    }
}

 

위와 같이 설정하고 나면 .hbs 파일에서 스프링 시큐리티 기능을 사용할 수 있게 된다

..
{{#sec "hasAnyRole('ADMIN', 'SUPER_ADMIN')"}}
  <p>관리자입니다</p>
{{/sec}}

{{#sec "hasRole('SUPER_ADMIN')"}}
  <p>관리자입니다</p>
{{/sec}}

{{#sec "!isAnonymous()"}}
  <p>관리자입니다</p>
{{/sec}}
..

 

 

메서드 수준의 권한 설정

요청 경로에 대한 권한 설정 뿐만 아니라 메서드 수준에서 보안을 적용할 수도 있다

 

예를 들어 해당 메서드를 최고 관리자만 실행할 수 있게 하려면 아래와 같이 @Secured 애너테이션을 달면 된다

..
@PostMapping("/upload")
@Secured(UserRole.FullName.SUPER_ADMIN)
public String upload(HttpServletRequest request) {
..

 

일반 관리자 권한을 가진 사용자가 해당 메서드를 호출하게 되면 org.springframework.security.access.AccessDeniedException: Access is denied 예외를 던진다