현재 진행중인 과제에서 캐모마일에서 지원하는 권한 기능에, 추가적으로 프로젝트별로 권한을 부여하려고 계획중입니다.
혹시 이러한 방식으로 개발을 진행하는것이 가능할까요?
1.1. 개발 순서
프로젝트 별 권한 처리를 수행하기 위해서는 아래와 같이 모듈을 개발하여 처리해야합니다.
- 로그인 시 프로젝트 별 권한 로드
- ProjectAuthenticationProvider : 프로젝트 마다 사용자에게 부여된 권한 정보를 로드한다.
- ProjectAuthentication : 사용자에게 부여 된 모든 권한 정보를 저장하는 객체
- ProjectGrantedAuthority : 프로젝트에 부여 된 권한 정보
- 매 요청시 마다 프로젝트이름을 적재하고, 접근제어 처리
- ProjectContextHolder
- ProjectContextFilter
- 모듈 설정
- 테스트
모듈 동작에 대한 개략적인 시퀀스 다이어그램은 아래와 같습니다.

1.2 로그인 시 프로젝트별 권한 로드
1.2.1. ProjcetAuthenticationProvider
유저가 로그인을 시도할 때, 유저가 데이터베이스로부터 모든 프로젝트별 권한을 가져와야 합니다. 그
후 프로젝트별 권한을 관리하는 ProjectAuthentication 클래스를 만들고, 해당 클래스가 기존의
AuthenticationProvider 를 대체하여 동작하도록 Main의 설정클래스에 등록합니다.
유저의 프로젝트별 ROLE은 DB구성에 따라 다양한 형태로 구현할 수 있습니다. ex) table(’project_user_role’)

JdbcTemplate 을 잘 활용하여 loadProjectAuthorities() 메소드에서 List<ProjectGrantedAuthority> 의 형태로 return하기만 하면 됩니다.
import java.util.List;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
public class ProjectAuthenticationProvider extends DaoAuthenticationProvider {
private final JdbcTemplate jdbcTemplate;
//해당 쿼리는 구현에따라 달라질 수 있다.
private static String PROJECT_AUTHORITIES_BY_USERNAME_QUERY = "SELECT A.PROJECT_ID, A.ROLE_ID"
+ "FROM CHMM_PROJECT_USER_ROLE A "
+ "WHERE A.USER_ID = ?";
public ProjectAuthenticationProvider(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
//DaoAuthenticationProvider의 패스워드 인코드 업그레이드 전략을 사용하기 위해서 부모클래스에서 호출
super.createSuccessAuthentication(principal, authentication, user);
// project authorities
List<ProjectGrantedAuthority> projectGrantedAuthorities = loadProjectAuthorities(
user.getUsername());
ProjectAuthentication result = new ProjectAuthentication(principal, authentication.getCredentials(),
user.getAuthorities(), projectGrantedAuthorities);
result.setDetails(authentication.getDetails());
return result;
}
//JDBC를 사용하여 필요에 맞게 권한을 가져온 후 리스트에 담는다.
protected List<ProjectGrantedAuthority> loadProjectAuthorities(String username) {
return jdbcTemplate.query(PROJECT_AUTHORITIES_BY_USERNAME_QUERY,
new String[]{username}, (rs, rowNum) -> {
String projectName = rs.getString(1);
String roleName = rs.getString(2);
return new ProjectGrantedAuthority(projectName, roleName);
});
}
}
1.2.2. ProjectAuthentication
로그인을 진행 했을 때 프로젝트별로 가지고있는 권한을 Map<String,Collection>형태로 초기에 모두 세팅
을 해주어야합니다. 그래야 request마다 변경되는 project값을 ProjectContextHolder에서 가져와서 해당
project에 유저가 가지고있는 권한을 getAuthorities() 메소드를 재정의하여 호출할 수 있습니다. 위와같이 구
현을 진행하게 되면 project가 달라짐에 따라 유저는 속해있는 project별 권한과 기본권한이 적용되고,
project가 없는 경우에는 기본권한만 적용됩니다.
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
public class ProjectAuthentication extends UsernamePasswordAuthenticationToken {
private final Map<String, Collection<ProjectGrantedAuthority>> projectAuthorities;
public ProjectAuthentication(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities,
Collection<? extends ProjectGrantedAuthority> projectAuthorities) {
super(principal, credentials, authorities);
Map<String, Collection<ProjectGrantedAuthority>> projectAuthMap = new HashMap<>();
for (ProjectGrantedAuthority authority : projectAuthorities) {
if (authority == null) {
throw new IllegalArgumentException(
"Authorities collection cannot contain any null elements");
}
Collection<ProjectGrantedAuthority> grantedAuthorities = projectAuthMap.getOrDefault(
authority.getProject(),
new ArrayList<>());
grantedAuthorities.add(authority);
projectAuthMap.put(authority.getProject(), grantedAuthorities);
}
this.projectAuthorities = Collections.unmodifiableMap(projectAuthMap);
}
@Override
public Collection<GrantedAuthority> getAuthorities() {
if (ProjectContextHolder.getProject() == null) {
return super.getAuthorities();
}
Collection<GrantedAuthority> authorities = new ArrayList<>(super.getAuthorities());
Collection<ProjectGrantedAuthority> projectAuthority = projectAuthorities.get(
ProjectContextHolder.getProject());
authorities.addAll(projectAuthority);
return authorities;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ProjectAuthentication)) {
return false;
}
if (!super.equals(o)) {
return false;
}
ProjectAuthentication that = (ProjectAuthentication) o;
return Objects.equals(projectAuthorities, that.projectAuthorities);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), projectAuthorities);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append("; ");
if (!projectAuthorities.isEmpty()) {
sb.append("Project Authorities: ");
Set<String> keys = projectAuthorities.keySet();
for (String project : keys) {
sb.append(project).append("=");
Collection<ProjectGrantedAuthority> authorities = projectAuthorities.get(project);
int i = 0;
for (ProjectGrantedAuthority authority : authorities) {
if (i++ > 0) {
sb.append(", ");
}
sb.append(authority.getAuthority());
}
}
} else {
sb.append("Not granted project authorities");
}
return sb.toString();
}
}
1.2.3. ProjectGrantedAuthority
기본적으로 적용되어있는 SimpleGrantedAuthority 대신에 우리는 project별로 권한이 달라져야하기때문
에 GrantedAuthority 를 ProjectGrantedAuthority로 구현하고 해당 Authority를 사용합니다.
import java.util.Objects;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import org.springframework.util.Assert;
public class ProjectGrantedAuthority implements GrantedAuthority {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final String project;
private final String role;
public ProjectGrantedAuthority(String project, String role) {
Assert.hasText(project, "A granted authority project textual representation is required");
Assert.hasText(role, "A granted authority role textual representation is required");
this.project = project;
this.role = role;
}
public String getProject() {
return project;
}
@Override
public String getAuthority() {
return role;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ProjectGrantedAuthority)) {
return false;
}
ProjectGrantedAuthority that = (ProjectGrantedAuthority) o;
return project.equals(that.project) && role.equals(that.role);
}
@Override
public int hashCode() {
return Objects.hash(project, role);
}
@Override
public String toString() {
return "ProjectGrantedAuthority{" +
"project='" + project + '\'' +
", role='" + role + '\'' +
'}';
}
}
1.3. 매 요청시 마다 프로젝트 이름을 적재하고, 접근제어 처리
1.3.1. ProjectContextFilter
우선 프로젝트별 권한값을 반환받기 위해서는 httpRequest로부터 project 값을 전달받아야합니다. 해당
request가 유지되는동안 project 값 또한 유지되어야하기때문에 스레드로컬에 해당 project 값을 저
장합니다. 종료된 후에는 스레드로컬의 값을 삭제해야하기때문에 try-finally구문을 사용하여 다음과 같이
구현합니다.
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
public class ProjectContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
//TODO: request에서 project 값을 뽑아서 원하는 방식으로 project를 넣어주시면 됩니다.(e.g. pathVariable,queryString)
ProjectContextHolder.setProject(request.getParameter("project"));
try {
filterChain.doFilter(request, response);
} finally {
ProjectContextHolder.reset();
}
}
}
1.3.2. ProjectContextHolder
filter에서 스레드로컬을 구현하고, 유지되고있는 값을 제어하는 메소드를 구현할 수도 있지만 project라
는 값을 스레드로컬에서 제어하는 기능을 하는 ProjectContextHolder 클래스를 따로 만들어 분리하
도록 합니다.
스레드로컬의 값을 삭제/ 삽입/ 호출 하는 단순한 기능만 구현하면 됩니다.
import org.springframework.lang.Nullable;
public final class ProjectContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
private ProjectContextHolder() {
}
public static void reset() {
contextHolder.remove();
}
public static void setProject(@Nullable String project) {
contextHolder.set(project);
}
public static String getProject() {
return contextHolder.get();
}
}
1.4. 모듈설정
아래와 같이 개발 된 모듈을 설정합니다.
import javax.sql.DataSource;
import net.lotte.chamomile.autoconfigure.core.ChamomileApplication;
import net.lotte.chamomile.autoconfigure.core.ChamomileBootApplication;
import net.lotte.chamomile.security.userdetails.JdbcUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import pms.config.ProjectAuthenticationProvider;
import pms.config.ProjectContextFilter;
import pms.handler.SampleLoginFailureHandler;
import pms.handler.SampleLoginSuccessHandler;
@ChamomileBootApplication
public class ChamomileSampleApplication {
public static void main(String[] args) {
ChamomileApplication.run(ChamomileSampleApplication.class, args);
}
@Configuration
protected static class CustomSecurityConfiguration
extends net.lotte.chamomile.configure.security.SecurityConfiguration {
@Autowired
private JdbcUserDetailsService customUserDetailsService;
@Autowired
private DataSource dataSource;
@Bean
@Override
public AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SampleLoginSuccessHandler();
}
@Bean
@Override
public AuthenticationFailureHandler authenticationFailureHandler() {
return new SampleLoginFailureHandler();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.addFilterBefore(new ProjectContextFilter(), FilterSecurityInterceptor.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
ProjectAuthenticationProvider projectAuthenticationProvider = new ProjectAuthenticationProvider(dataSource);
projectAuthenticationProvider.setUserDetailsService(customUserDetailsService);
projectAuthenticationProvider.setPasswordEncoder(passwordEncoder());
auth.authenticationProvider(projectAuthenticationProvider);
}
}
}
1.5 테스트
Sample 컨트롤러를 생성하여 원하는 권한이 올바르게 적용되었는지 테스트합니다.
import org.springframework.lang.Nullable;
public final class ProjectContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
private ProjectContextHolder() {
}
public static void reset() {
contextHolder.remove();
}
public static void setProject(@Nullable String project) {
contextHolder.set(project);
}
public static String getProject() {
return contextHolder.get();
}
}