1. 개요

Security은 애플리케이션 개발 세계, 특히 엔터프라이즈 웹 및 모바일 애플리케이션 영역에서 주요 관심사입니다.

이 빠른 사용방법(예제)에서는 두 가지 인기 있는 Java Security 프레임워크인 Apache ShiroSpring Security를 ​​비교합니다 .

2. 약간의 배경

Apache Shiro는 2004년에 JSecurity로 탄생했으며 2008년에 Apache Foundation에 의해 승인되었습니다. 현재까지 많은 릴리스가 있었으며 이 문서를 작성하는 시점의 최신 버전은 1.5.3입니다.

Spring Security는 2003년에 Acegi로 시작하여 2008년 첫 공개 릴리스와 함께 Spring Framework에 통합되었습니다. 처음부터 여러 번의 반복을 거쳤으며 현재 GA 버전은 5.3.2입니다.

두 기술 모두 암호화 및 세션 관리 솔루션과 함께 인증 및 권한 부여 지원을 제공합니다 . 또한 Spring Security는 CSRF 및 세션 고정과 같은 공격에 대해 최고 수준의 보호 기능을 제공합니다.

다음 몇 섹션에서는 두 기술이 인증 및 권한 부여를 처리하는 방법에 대한 예를 살펴보겠습니다. 간단하게 유지하기 위해 FreeMarker 템플릿 과 함께 기본 Spring Boot 기반 MVC 애플리케이션을 사용할 것입니다 .

3. 아파치 시로 구성

먼저 두 프레임워크 간의 구성이 어떻게 다른지 살펴보겠습니다.

3.1. 메이븐 의존성

Spring Boot 앱에서 Shiro를 사용할 것이므로 스타터와 shiro-core 모듈이 필요합니다.

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.5.3</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.5.3</version>
</dependency>

최신 버전은 Maven Central 에서 찾을 수 있습니다 .

3.2. 영역 만들기

역할과 권한이 있는 사용자를 메모리 내에서 선언하려면 Shiro의 JdbcRealm을 확장하는 영역을 만들어야 합니다 . 각각 USER 및 ADMIN 역할을 가진 두 명의 사용자(Tom 및 Jerry)를 정의합니다.

public class CustomRealm extends JdbcRealm {

    private Map<String, String> credentials = new HashMap<>();
    private Map<String, Set> roles = new HashMap<>();
    private Map<String, Set> permissions = new HashMap<>();

    {
        credentials.put("Tom", "password");
        credentials.put("Jerry", "password");

        roles.put("Jerry", new HashSet<>(Arrays.asList("ADMIN")));
        roles.put("Tom", new HashSet<>(Arrays.asList("USER")));

        permissions.put("ADMIN", new HashSet<>(Arrays.asList("READ", "WRITE")));
        permissions.put("USER", new HashSet<>(Arrays.asList("READ")));
    }
}

다음으로 이 인증 및 권한 부여 검색을 활성화하려면 몇 가지 방법을 재정의해야 합니다.

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
  throws AuthenticationException {
    UsernamePasswordToken userToken = (UsernamePasswordToken) token;

    if (userToken.getUsername() == null || userToken.getUsername().isEmpty() ||
      !credentials.containsKey(userToken.getUsername())) {
        throw new UnknownAccountException("User doesn't exist");
    }
    return new SimpleAuthenticationInfo(userToken.getUsername(), 
      credentials.get(userToken.getUsername()), getName());
}

@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    Set roles = new HashSet<>();
    Set permissions = new HashSet<>();

    for (Object user : principals) {
        try {
            roles.addAll(getRoleNamesForUser(null, (String) user));
            permissions.addAll(getPermissions(null, null, roles));
        } catch (SQLException e) {
            logger.error(e.getMessage());
        }
    }
    SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo(roles);
    authInfo.setStringPermissions(permissions);
    return authInfo;
}

doGetAuthorizationInfo 메서드는 두 가지 도우미 메서드를 사용하여 사용자의 역할과 권한을 가져옵니다.

@Override
protected Set getRoleNamesForUser(Connection conn, String username) 
  throws SQLException {
    if (!roles.containsKey(username)) {
        throw new SQLException("User doesn't exist");
    }
    return roles.get(username);
}

@Override
protected Set getPermissions(Connection conn, String username, Collection roles) 
  throws SQLException {
    Set userPermissions = new HashSet<>();
    for (String role : roles) {
        if (!permissions.containsKey(role)) {
            throw new SQLException("Role doesn't exist");
        }
        userPermissions.addAll(permissions.get(role));
    }
    return userPermissions;
}

다음으로 이 CustomRealm을 부트 애플리케이션의 빈으로 포함해야 합니다 .

@Bean
public Realm customRealm() {
    return new CustomRealm();
}

또한 엔드포인트에 대한 인증을 구성하려면 다른 bean이 필요합니다.

@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
    DefaultShiroFilterChainDefinition filter = new DefaultShiroFilterChainDefinition();

    filter.addPathDefinition("/home", "authc");
    filter.addPathDefinition("/**", "anon");
    return filter;
}

여기에서 DefaultShiroFilterChainDefinition 인스턴스를 사용하여 인증된 사용자만 /home 엔드포인트에 액세스할 수 있도록 지정했습니다 .

이것이 구성에 필요한 전부이며 Shiro가 나머지 작업을 수행합니다.

4. 스프링 Security 설정

이제 Spring에서 동일한 작업을 수행하는 방법을 살펴보겠습니다.

4.1. 메이븐 의존성

먼저 의존성:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

최신 버전은 Maven Central 에서 찾을 수 있습니다 .

4.2. 구성 클래스

다음으로 SecurityConfig 클래스에서 Spring Security 구성을 정의합니다 .

@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf()
            .disable()
            .authorizeRequests(authorize -> authorize.antMatchers("/index", "/login")
                .permitAll()
                .antMatchers("/home", "/logout")
                .authenticated()
                .antMatchers("/admin/**")
                .hasRole("ADMIN"))
            .formLogin(formLogin -> formLogin.loginPage("/login")
                .failureUrl("/login-error"));
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() throws Exception {
        UserDetails jerry = User.withUsername("Jerry")
            .password(passwordEncoder().encode("password"))
            .authorities("READ", "WRITE")
            .roles("ADMIN")
            .build();
        UserDetails tom = User.withUsername("Tom")
            .password(passwordEncoder().encode("password"))
            .authorities("READ")
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(jerry, tom);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

보시다시피 UserDetails  개체를 만들어 사용자의 역할과 권한을 선언했습니다. 또한 BCryptPasswordEncoder 를 사용하여 암호를 인코딩했습니다 .

Spring Security는 또한 추가 구성을 위해 HttpSecurity 객체를 제공합니다 . 이 예에서는 다음을 허용했습니다.

  • 누구나 색인로그인 페이지 에 액세스할 수 있습니다.
  • 인증된 사용자만 홈페이지 진입 로그아웃
  • 관리자 페이지에 액세스할 수 있는 ADMIN 역할을 가진 사용자만

또한 사용자를 로그인 Endpoints 으로 보내는 양식 기반 인증에 대한 지원을 정의했습니다 . 로그인에 실패하면 사용자는 /login-error 로 리디렉션됩니다 .

5. 컨트롤러 및 엔드포인트

이제 두 애플리케이션에 대한 웹 컨트롤러 매핑을 살펴보겠습니다. 동일한 Endpoints을 사용하지만 일부 구현은 다를 수 있습니다.

5.1. 뷰 렌더링을 위한 Endpoints

보기를 렌더링하는 Endpoints의 경우 구현은 동일합니다.

@GetMapping("/")
public String index() {
    return "index";
}

@GetMapping("/login")
public String showLoginPage() {
    return "login";
}

@GetMapping("/home")
public String getMeHome(Model model) {
    addUserAttributes(model);
    return "home";
}

컨트롤러 구현인 Shiro와 Spring Security 모두 루트 Endpoints에서 index.ftl , 로그인 Endpoints에서 login.ftl , 홈 Endpoints에서 home.ftl을 반환합니다.

그러나 /home 엔드포인트 에 있는 addUserAttributes 메서드의 정의는 두 컨트롤러 간에 다릅니다. 이 메소드는 현재 로그인한 사용자의 속성을 검사합니다.

Shiro는 SecurityUtils#getSubject를 제공하여 현재 Subject 와 해당 역할 및 권한을 검색합니다.

private void addUserAttributes(Model model) {
    Subject currentUser = SecurityUtils.getSubject();
    String permission = "";

    if (currentUser.hasRole("ADMIN")) {
        model.addAttribute("role", "ADMIN");
    } else if (currentUser.hasRole("USER")) {
        model.addAttribute("role", "USER");
    }
    if (currentUser.isPermitted("READ")) {
        permission = permission + " READ";
    }
    if (currentUser.isPermitted("WRITE")) {
        permission = permission + " WRITE";
    }
    model.addAttribute("username", currentUser.getPrincipal());
    model.addAttribute("permission", permission);
}

반면 Spring Security는 이러한 목적을 위해 SecurityContextHolder 의 컨텍스트 에서 Authentication 객체를 제공합니다.

private void addUserAttributes(Model model) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null && !auth.getClass().equals(AnonymousAuthenticationToken.class)) {
        User user = (User) auth.getPrincipal();
        model.addAttribute("username", user.getUsername());
        Collection<GrantedAuthority> authorities = user.getAuthorities();

        for (GrantedAuthority authority : authorities) {
            if (authority.getAuthority().contains("USER")) {
                model.addAttribute("role", "USER");
                model.addAttribute("permissions", "READ");
            } else if (authority.getAuthority().contains("ADMIN")) {
                model.addAttribute("role", "ADMIN");
                model.addAttribute("permissions", "READ WRITE");
            }
        }
    }
}

5.2. POST 로그인 Endpoints

Shiro에서는 사용자가 POJO에 입력하는 자격 증명을 매핑합니다.

public class UserCredentials {

    private String username;
    private String password;

    // getters and setters
}

그런 다음 UsernamePasswordToken을 생성하여 사용자 또는 Subject를 로그인 합니다.

@PostMapping("/login")
public String doLogin(HttpServletRequest req, UserCredentials credentials, RedirectAttributes attr) {

    Subject subject = SecurityUtils.getSubject();
    if (!subject.isAuthenticated()) {
        UsernamePasswordToken token = new UsernamePasswordToken(credentials.getUsername(),
          credentials.getPassword());
        try {
            subject.login(token);
        } catch (AuthenticationException ae) {
            logger.error(ae.getMessage());
            attr.addFlashAttribute("error", "Invalid Credentials");
            return "redirect:/login";
        }
    }
    return "redirect:/home";
}

Spring Security 측에서 이것은 홈 페이지로의 리디렉션 문제일 뿐입니다. UsernamePasswordAuthenticationFilter 에 의해 처리되는 Spring의 로그인 프로세스는 우리에게 투명합니다 .

@PostMapping("/login")
public String doLogin(HttpServletRequest req) {
    return "redirect:/home";
}

5.3. 관리자 전용 Endpoints

이제 역할 기반 액세스를 수행해야 하는 시나리오를 살펴보겠습니다. ADMIN 역할에만 액세스가 허용되어야 하는 /admin 엔드포인트 가 있다고 가정해 보겠습니다 .

Shiro에서 이 작업을 수행하는 방법을 살펴보겠습니다.

@GetMapping("/admin")
public String adminOnly(ModelMap modelMap) {
    addUserAttributes(modelMap);
    Subject currentUser = SecurityUtils.getSubject();
    if (currentUser.hasRole("ADMIN")) {
        modelMap.addAttribute("adminContent", "only admin can view this");
    }
    return "home";
}

여기에서 현재 로그인한 사용자를 추출하고 ADMIN 역할이 있는지 확인하고 그에 따라 콘텐츠를 추가했습니다.

Spring Security에서는 프로그래밍 방식으로 역할을 확인할 필요가 없으며 SecurityConfig에서 누가 이 엔드포인트에 도달할 수 있는지 이미 정의 했습니다 . 이제 비즈니스 로직을 추가하기만 하면 됩니다.

@GetMapping("/admin")
public String adminOnly(HttpServletRequest req, Model model) {
    addUserAttributes(model);
    model.addAttribute("adminContent", "only admin can view this");
    return "home";
}

5.4. 로그아웃 Endpoints

마지막으로 로그아웃 Endpoints을 구현해 보겠습니다.

Shiro에서는 단순히 Subject#logout을 호출합니다 .

@PostMapping("/logout")
public String logout() {
    Subject subject = SecurityUtils.getSubject();
    subject.logout();
    return "redirect:/";
}

Spring의 경우 로그아웃에 대한 매핑을 정의하지 않았습니다. 이 경우 구성에서 SecurityFilterChain 빈을 생성한 이후 자동으로 적용되는 기본 로그아웃 메커니즘이 시작됩니다.

6. 아파치 시로 VS 스프링 시큐리티

이제 구현 차이점을 살펴보았으므로 몇 가지 다른 측면을 살펴보겠습니다.

커뮤니티 지원 측면에서 Spring Framework에는 일반적으로 개발 및 사용에 적극적으로 참여하는 거대한 개발자 커뮤니티가 있습니다 . Spring Security는 우산의 일부이므로 동일한 이점을 누릴 수 있어야 합니다. Shiro는 인기가 있지만 그렇게 엄청난 지원을 받지 못합니다.

문서와 관련하여 Spring이 다시 승자입니다.

그러나 Spring Security와 관련된 약간의 학습 곡선이 있습니다. 반면 Shiro는 이해하기 쉽습니다 . 데스크톱 애플리케이션의 경우 shiro.ini를 통한 구성이 훨씬 더 쉽습니다.

그러나 예제 스니펫에서 본 것처럼 Spring Security는 비즈니스 로직과 Security을 분리 하는 훌륭한 작업을 수행 하고 교차 절단 문제로 Security을 제공합니다.

7. 결론

이 예제에서는 Apache Shiro와 Spring Security를 ​​비교했습니다 .

우리는 이러한 프레임워크가 제공해야 하는 것의 표면을 스쳐 지나갔고 더 탐구할 것이 많습니다. JAASOACC 와 같은 몇 가지 대안이 있습니다 . 여전히 장점이 있는 Spring Security가 이 시점에서 승리하는 것 같습니다.

항상 그렇듯이 소스 코드는 GitHub에서 사용할 수 있습니다 .

res – Security (video) (cat=Security/Spring Security)