현재 진행중인 과제에서 캐모마일에서 지원하는 권한 기능에, 추가적으로 프로젝트별로 권한을 부여하려고 계획중입니다.
혹시 이러한 방식으로 개발을 진행하는것이 가능할까요?
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(); } }