현 상태
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;
}
}
이제 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 예외를 던진다