본문 바로가기

3.구현/Java or Kotlin

[Java] spring boot에서 spring security 사용하기 2 - Authorization

들어가기

Spring Security 두번째 글로 이전에 다룬 인증을 기반으로 사용자 접근제어을 다룰려고 한다. Spring Security에서는 사용자 권한에 따라 접근할 수 있는 URL을 관리할 수 있다. 간단하게 Spring Security의 접근 제어를 살펴볼자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

접근제어란?

접근 제어란 서비스에 있는 모든 기능을 아무 사용할 수도 있지만, 대부분 권한에 따라 접근을 허용하거나 차단한다. 예를 들어 시스템 사용자에 일반 사용자와 관리자가 있다면, 일반 사용자는 자신의 데이터만 접근할 수 있지만, 관리자는 시스템 전체에 대한 데이터를 접근할 수 있다.

Spring Security에서 할당된 사용자 권한을 가지고 URL을 기반으로 접근 여부를 지정할 수 있다.

HTTP 요청에 대한 접근 제한

먼저 HTML 페이지 부터 작업을 시작해보자.

HTML 페이지

사용자 등록 페이지(register.html)

사용자 정보를 입력하는 HTML 폼 페이지이다. POST 형식으로 "/register"으로 호출하고 있다. /resource/static/ 디렉토리에 register.html 파일을 생성해서 아래 내용을 입력한다. 추가된 부분은 사용자 권한를 선택하는 라디오 버튼이다.

<html>
<head>
<title>Register</title>
</head>
<body>
    <form action="/register" method="post">
        <div>
            <input type="radio" id="role1" name="role" value="BASIC" checked>
            <label for="role1">일반사용자</label>
            <input type="radio" id="role2" name="role" value="ADMIN">
            <label for="role2">관리자</label>
        </div>
        <div>
            <input type="text" name="id" placeholder="UserID"/>
        </div>
        <div>
            <input type="password"  name="pwd" placeholder="Password"/>
        </div>
        <div>
            <input type="submit" value="가입하기"/>
        </div>
    </form>
</body>
</html>

아래는 수정된 사용자 등록 페이지 모습이다.

Fig 1. 사용자 등록 페이지

기본 페이지(index.html)

홈페이지에 접속시 표시될 기본 페이지를 작성한다. /resource/static 디렉토리에 html파일을 생성하면 된다. 단순 링크만 있는 페이지이다. 추가된 부분은 Admin 링크이다.

<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
    <h1>Home</h1>
    <nav>
        <a href="/register.html">Register</a>
        <a href="/login.html">Login</a>
        <a href="/logout">Logout</a>
        <a href="/user/index.html">User</a>
        <a href="/admin/index.html">Admin</a>
    </nav>
</body>
</html>

아래는 수정된 기본 페이지 모습이다.

Fig 2. 기본 페이지

로그인 페이지(/login.html)

로그인 페이지도 특별하게 수정할 내용이 없다.

<html>
<head>
<title>Login</title>
</head>
<body>
    <h1>Login</h1>
    <form method="POST">
        <div>
            <input type="radio" id="role1" name="role" value="USER">
            <label for="role1">일반사용자</label>
            <input type="radio" id="role2" name="role" value="ADMIN">
            <label for="role2">관리자</label>
        </div>
        <div>
            <input type="text" name="id" placeholder="UserID"/>
        </div>
        <div>
            <input type="password"  name="pwd" placeholder="Password"/>
        </div>
        <div>
            <input type="submit" value="가입하기"/>
        </div>
    </form>
</body>
</html>

아래는 로그인 페이지 모습이다.

Fig 3. 로그인 페이지

로그아웃 페이지(/thankyou.html)

로그 아웃 페이지이다.

<html>
<head>
<title>Thank You</title>
</head>
<body>
    <h1>Thank You</h1>
    <a href="/">Home</a>
</body>
</html>

아래는 로그아웃 페이지 모습니다.

Fig 4. 로그아웃 페이지

사용자 페이지(/user/index.html)

“BASIC” 권한을 갖는 일반 사용자가 보이는 페이지이다. 별다른 변경사항은 없다.

<html>
<head>
<title>User</title>
</head>
<body>
    <h1>Hello User.</h1>
    <nav>
        <a href="/">Home</a>
        <a href="/logout">Logout</a>
    </nav>
</body>
</html>

아래는 사용자 페이지 모습이다.

Fig 5. 사용자 페이지

관리자 페이지(/admin/index.html)

관리자에게 보이는 페이지이다. "/resource/static/admin/index.html" 파일을 생성한다. 관리자 페이지는 "ADMIN" 역할을 가진 사용자가 접근할 수 있는 페이지이다. 물론 내용은 일반 사용자 페이지와 거의 동일하다.

<html>
<head>
<title>Admin</title>
</head>
<body>
    <h1>Hello Admin.</h1>
    <nav>
        <a href="/">Home</a>
        <a href="/logout">Logout</a>
    </nav>
</body>
</html>

아래는 새로 추가된 관리자 페이지 모습이다.

Fig 6. 관리자 페이지

접근거부 페이지(/denied.html)

접근 권한이 없는 사용자가 접근하는 경우 표시하는 페이지이다. 예를 들어 일반 사용자가 관리자 페이지에 접근하는 경우이다.

<html>
<head>
<title>Access Denied<</title>
</head>
<body>
    <h1>Access Denied</h1>
    <a href="/">Home</a>
</body>
</html>

아래는 접근 거부 페이지 모습이다.

Fig 7. 접근 거부 페이지

사용자 관리

먼저 기본적인 사용자 등록 관리를 수정해야 한다. 접근제어를 위한 사용자 권한 정보를 관리해야 한다.

User 클래스

먼저 사용자 정보를 저장할 클래스를 정의해보자. 사용자 권한을 관리할 role가 추가되었다.

public class User {
    private String id;
    private String pwd;
    private String role;

    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    public String getPwd() {
        return pwd;
    }
    public void setPwd(String pwd) {
        this.pwd = pwd;
    }
    public String getRole() {
        return role;
    }
    public void setRole(String role) {
        this.role = role;
    }
} 

MainControler 클래스

사용자 요청 처리하는 MainController이다. 별다른 수정사항은 없다.

@Controller
public class MainController {
    @Autowired
    private UserService userService;

    @PostMapping("/register")
    public String register(User user) {
        userService.register(user);

        return "redirect:/";
    }
}

UserService 클래스

사용자 서비스 처리하는 UserService이다. 별다른 수정사항이 없다.

@Service
public class UserService implements UserDetailsService {
    @Autowired
    private UserRepository userRepo;

    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    public  void setPasswordEncoder(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.get(username);
        if (null == user) throw new UsernameNotFoundException(username);
        return new SecurityUser(user);
    }

    public void register(User user) {
        user.setPwd(passwordEncoder.encode(user.getPwd()));
        userRepo.add(user);
    }
}

SecurityUser 클래스

이전에는 사용자 id와 패스워드만 전달했지만, 사용자 권한 정보도 넘겨줘야 한다. Spring Security의 User 클래스을 수정해야한다. SecurityUser 클래스에서 User 클래스를 받아서 role에 있는 문자열을 GrantedAuthority 리스트로 변환해야한다.

public class SecurityUser extends  org.springframework.security.core.userdetails.User {
    private static final String ROLE_PREFIX = "ROLE_";
    public SecurityUser(User user) {
        super(user.getId(), user.getPwd(), List.of(new SimpleGrantedAuthority(ROLE_PREFIX + user.getRole())));
    }
}

Spring Security 설정

다음으로 사용자 접근제어를 해보겠다. 앞의 인증 단계는 사용자를 식별하는 단계로 해당 사용자가 맞는지 확인하는 단계이다. 여기서는 특정 자원에 대해서 해당 사용자가 사용해도 되는지 결정하는 단계이다. 때에 따라서 접근 제한이 되면서 거부된다. 특정 페이지에 접근하는 경우에 인증된 사용자만 허용하게 만들 수 있다. 루트 밑에 있는 “/index.html”, “/register.html”, “/register”, “/login.html”, “/thankyou.html”, “/denied.html” 경로는 모두 허용된다. 그리고 “/user” 서브 경로는 인증된 사용자만 접근할 수 있고 “/admin” 서브 경로는 관리자만 허용한다.

경로에 따라서 접근 제어를 처리해보자.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private UserService userService;

    @Bean
    PasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    DaoAuthenticationProvider authProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();

        PasswordEncoder encoder = encoder();
        userService.setPasswordEncoder(encoder);
        authProvider.setUserDetailsService(userService);
        authProvider.setPasswordEncoder(encoder);

        return authProvider;
    }

    @Bean
    WebSecurityCustomizer securityCustomize() {
        return (web)->web.ignoring().requestMatchers("/image/**");
    }
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);
        http
                .authorizeHttpRequests(req->req
                        .requestMatchers("/*".permitAll()
                        .requestMatchers("/admin/*").hasRole("ADMIN")
                        .requestMatchers("/user/*").hasRole("USER")
                        .anyRequest().authenticated()
                )
                .formLogin(login->login
                        .loginPage("/login.html")
                        .failureUrl("/login.html")
                )
                .logout(logout->logout
                        .logoutSuccessUrl("/thankyou.html")
                )
                .exceptionHandling(ex->ex
                        .accessDeniedPage("/denied.html")
                )
        ;

        return http.build();
    }

    @Bean
    RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
        hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
        return hierarchy;
    }
}

“/”로 루트 바로 밑에 페이지 URL 경로에 대해서 permitAll()를 통해서 모든 접근을 허용하고 있다. "/user/*\"에 의해서 user 서브 경로에 있는 모든 리소스에 대해서 authenticated()로서 인증된 사용자만 접근을 허용한다. "/admin" 서브 경로에 있는 모든 리소스에 대해 "ADMIN" 역할을 가진 사용자만 접근하도록 설정했다. hasRole(”ADMIN”)은 hasAuthority(”ROLE_ADMIN”)의 축약형이다. “/user” 서브 경로에 대해서도 “USER”역할을 가진 사용자만 허용했다.

이렇게 할 경우 관리자는 일반 사용자 페이지에 접근하지 못한다. 이를 허용해보자. 즉, 관리자 권한이 사용자 권한보다 높기때문에 이를 지정하기 RoleHierarchy 빈 객체를 생성한다. 권한 트리 구조를 "ROLE_ADMIN > ROLE_USER”으로 설정하여 관리자가 상위 권한으로 지정한다.

주의할 부분은 경로에 따른 접근 권한 지정할 때에 순서에 의해 가장 먼저 매칭된 조건으로 처리가 된다.

접근제어 테스트

일반사용자

  1. 일반사용자로 사용자 등록한다.
  2. User 링크로 이동하면 사용자 인증 페이지가 표시된다.
  3. 사용자 인증을 성공하면 User 페이지로 이동된다.
  4. Home으로 이동하고 Admin 페이지로 이동하면 접근거부 페이지가 표시된다.
  5. Home으로 이동하고 Logout 하면 로그아웃 페이지로 이동한다

관리자

  1. 관리자로 사용자 등록한다.
  2. Admin 링크로 이동하면 사용자 인증 페이지가 표시된다.
  3. 사용자 인증을 성공하면 Admin 페이지로 이동된다.
  4. Home으로 이동하고 User 페이지로 이동한다
  5. Home으로 이동하고 Logout 하면 로그아웃 페이지로 이동한다

메소드에 대한 접근 제한

메소드에 대한 접근 제어는 앞의 HTTP 요청에 대한 접근 제어와 동일하다고 생각할 수 있지만, 이는 좀더 정밀하게 각 메소드마다 접근 제어를 할 수 있다. 만약 컨트롤에 있는 메소드에 대해 접근 제어할 경우는 앞의 HTTP 요청에 대한 접근 제어와 거의 동일하다. 다른 점은 요청 메시지와 응답 메시지에 대한 내용을 가지고 접근 제어할 수 있다.

메소드에 접근 제한을 사용하려면 @EnableMethodSecurity를 SecurityConfig에 추가해야 한다.

@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
// 중략
}

메소드에 대한 접근 제어를 위한 어노테이션으로 @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter를 사용할 수 있다.

  • @PreAuthorize: 메소드나 클래스 호출 전에 승인 여부 판단
  • @PostAuthorize: 메소드나 클래스 호출 후에 승인 여부 판단
  • @PreFilter: 메소드 호출 시 컬랙션 인자를 필터링
  • @PostFilter: 메소드 호출 후 컬랙션 러턴에 대해 필터링

사용하는 방법은 간단하다 클래스나 메소드에 어노테이션을 추가하고 승인위한 조건을 입력하면 된다.

@Service
@PreAuthorize("hasRole('USER')")
public class UserService {
  @PostAuthorize("returnObject.id == authentication.name")
  public User getUserById(String id) { ... }
}

returnObject는 리턴되는 객체를 의미한다. authentication은 SecurityContext에 저장된 객체로 직접 principal 속성을 접근한다. 그래서 name은 UserDetail 객체의 username을 액세스한다.

사용자 탈퇴를 가지고 확인해보자. 일반 사용자는 본인이 직접 탈퇴 가능하지만 관리자는 다른 관리자에 의해서 삭제해야 된다고 가정하자.

HTML 페이지

사용자 페이지(/user/index.html)

사용자 페이지에 Drop out링크를 추가하자.

<html>
<head>
<title>User</title>
</head>
<body>
    <h1>Hello User.</h1>
    <nav>
        <a href="/">Home</a>
        <a href="/logout">Logout</a>
        <a href="/dropout">Drop out</a>
    </nav>
</body>
</html>

아래는 수정된 사용자 페이지 모습이다.

Fig 8. 사용자 페이지

관리자 페이지(/admin/index.html)

관리자 페이지에는 다른 관리자 삭제 폼을 추가하다.

<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Admin</title>
</head>
<body>
    <h1>Hello Admin</h1>
    <nav>
        <a href="/">Home</a>
        <a href="/logout">Logout</a>
    </nav>
    <form action="/removeUser" method="post">
        <input type="text" name="id" placeholder="UserID"/>
        <input type="submit" value="사용자삭제"/>
    </form>
</body>
</html>

간단한 처리를 위해서 form 형태로 처리했다. 아래는 수정된 관리자 페이지 화면이다.

Fig 9. 관리자 페이지

사용자 관리

사용자 탈퇴 처리와 관리자 삭제 처리를 해보자.

MainController 클래스

탈퇴 처리하는 dropout()과 사용자 삭제하는 deleteUser()를 추가한다.

@Controller
public class MainController {
    @GetMapping("/dropout")
    @PreAuthorize("hasRole('USER') && !hasRole('ADMIN')")
    public String dropout() {
        String id =  SecurityContextHolder.getContext().getAuthentication().getName();

        userService.dropout(id);
        ServletRequestAttributes requestAttr = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        if (null != requestAttr) {
            requestAttr.getRequest().getSession().invalidate();
        }

        return "redirect:/";
    }

    @ResponseBody
    @DeleteMapping("/api/user/{id}")
    @PreAuthorize("hasRole('ADMIN') && #id != authentication.name")
    public void deleteUser(@PathVariable String id) {
        userService.dropout(id);
    }
    // (중략)
}

dropout() 권한은 USER만 가능하고 ADMIN은 안되게 한다. 그리고 deleteUser() 권한은 ADMIN만 가능하고 자기 자신은 삭제하지 못하게 한다.

UserService 클래스

@Service
public class UserService implements UserDetailsService {
    public void dropout(String id) {
        userRepo.remove(id);
    }
    // (중략)
}

접근 권한 설정을 컨트롤 클래스에 지정했지만 서비스 클래스에도 지정할 수 있다.

접근제어 테스트

일반사용자

  1. 일반사용자로 사용자 등록하고 로그인 한다.
  2. User 페이지에서 Drop out 하면 Home으로 이동한다.

관리자

  1. 2명 관리자를 등록한다.
  2. User 페이지로 이동해서 Drop out하면 거부되는지 확인한다.
  3. Admin 페이지로 이동한다.
  4. 자신 계정을 입력해서 [사용자삭제] 버튼을 클릭해서 거부되는지 확인한다.
  5. 다른 관리자 계정을 입력해서 [사용자삭제] 버튼을 클릭해서 성공되는지 확인한다.

GitHub

프로젝트 경로: https://github.com/ospace/spring-works/tree/main/spring-security-02

결론

간단하게 Spring Security에서 접근 제어 기능을 살펴보았습니다. 단순하면서 실제 동작가능한 예제를 만들려고 하니 생각보다 시간이 걸리네요. HTML 페이지는 단순하게 처리하기 위해서 최대한Form 액션을 사용해서 처리했습니다. 위의 내용은 동작하는 방식에 대한 감을 잡기 위한 부분으로 실제 사용할 때에는 Spring Security의 기능을 참고하면 됩니다. 이외 다양한 접근 제어가 있지만 현재 내용을 가지고 참고하면 크게 어렵지 않을 거라고 생각합니다. 참고 자료에 더 자세히 설명되어 있습니다.

부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩 생활하세요. ^^ ospace.

참고

[1] Authorization - Authorize HTTP Requests, https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

[2] Authorization - Method Security, https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html

반응형