Spring Security - 2. Role과 권한(Privilege)

Role과 권한을 조회하는 UserDetailsService

이번 시간에는 SecurityContext에 보관되는 Authentication, Role과 권한(Authority 또는 Privilege)에 대해 알아보겠다.
경험을 바탕으로 Role과 권한의 차이는 무엇이고, 실습 예제로 어떻게 Spring Security에서 GrantedAuthority를 관리하는지 알아보자.

이번 글을 통해서 아래 내용을 이해하자.

  • Spring Security 기반 Role 과 권한 설계
  • 사용자 정보를 조회하는 UserDetailsService

참고로 Spring Security에서 GrantedAuthority와 일반적인 권한을 의미하는 단어로 Authority를 사용하면 단어가 비슷해 혼란을 줄 수 있다고 생각한다.
따라서 이하 본문에서는 권한을 Privilege로 표현하고, GrantedAuthority는 따로 설명을 진행하겠다.

1. Role과 권한(Privilege) 설계

Spring Security에서 Role과 권한(Privilege)을 어떻게 설계해야 된다는 직접적인 가이드는 없다. 기존에 플젝에서 경험을 Spring Security - Roles and Privileges 아티클 기반으로 내용을 정리해본다.

권한 설계 작업은 언제하는 것이 좋은가?

시스템에 따라 어떤 Role이 필요한지는 사용자 시나리오나 Persona 기반으로 도출할 수 있다.
하지만 어떤 권한이 필요한지 정의를 할때는 사용자 시나리오나 User Journey Map기반으로 진행하면, 상상을 기반으로 하기때문에 구체적인 상황을 고려하려 설계를 진행하기 어렵다.

개인적으로 권한(Privilege) 설계는 시스템이 어느 정도 개발이 되고 나서, 실물 기반으로 시스템 권한, 비지니스별 권한 설계하는 것이 구체적인 상황별로 생각을 확장해서 고민할 수 있어서 더 편했던 것 같다.

  • 장점
    • 눈에보이는 구체적인 상황을 기준으로 설계를 진행할 수 있어서 쉽게 작업할 수 있다.
    • 설계를 실물 기반으로 검증할 수 있어서 불확실성을 줄이고, 불필요한 작업을 할 확률을 줄인다.
  • 단점
    • Role, 권한 설계가 소스, 테스트 포함해서 시스템 전반에 영향을 끼치기 때문에 작업 범위가 커서 시간이 오래 걸릴 수 있다.
    • 작업 시점이 너무 늦지 않는 것이 중요하다. 작업 시점이 너무 늦으면 권한 설계로 인해 소스가 변경되야 하는 상황이 생기는데, 영향도가 크면 적용하기가 점점 어려워진다.

a. Role

Role은 시스템에서 사용하는 사용자의 역할을 의미한다. Role은 동시에 두 가지형태로 사용될 수 있다.

  1. 그 자체로 권한으로 사용할 수 있다.
  2. 권한(Privilege)을 담는 Container로써 사용할 수 있다.

b. 권한(Privilege)

시스템에서 사용하는 low-level의 권한을 의미한다. 앞에서 설명한 것처럼 Role은 권한을 담는 Container로써 역할을 수행할 수 있기 때문에, 권한 설계시에는 Role이 사용할 수 있는 모든 권한이 도출되어 있어야 한다.

  • Role과 Privilege 의 관계 : 다대다(N:N)
  • 권한은 Role별로 그룹핑 되고 관리되는 대상이다. 논리적으로 권한(Privilege 또는 Authoritiy)이 Role보다 더 작고 세밀하다.
Granularity(세밀함) - Role과 GrantedAuthority

c. Role과 권한(Privilege) 설계 예시

이전의 플젝 경험을 바탕으로, Role과 권한 관계를 어떻게 설계할 수 있는지 예를 들어 확인해보자.
Role가 권한(Privilege)은 시스템 전반에 영향을 끼치는 high-level 범위에서 설계가 되어야 한다. 사업부 체크와 같은 비지니스별 권한 체크는 여기서 제외하며, 추후에 구체적으로 다루도록 하겠다.

“경영지표를 확인하는 대쉬보드 시스템”

실적을 조회하는 대쉬보드 형태의 시스템이었고, Role은 일반 사용자와 사용자 관리를 할 수 있는 어드민 사용자로 구분이 되었다.

  • ROLE_USER (일반 사용자)
    • READ_AUTHORITY 만 가짐
  • ROLE_ADMIN (관리자)
    • WRITE_AUTHORITY : 사용자 관리, 메뉴 관리(등록, 수정, 삭제)
    • READ_AUTHORITY

“프로젝트의 공정, 일감을 관리하는 시스템”

누구나 사용을 할 수 있지만 회원가입을 한 최초 사용자는 “임시 사용자”로써 프로젝트 공지사항, 메일, 일정등 가장 기본적인 커뮤니케이션 기능만 사용할 수 있다.
프로젝트 관리자가 승인을 해주면, “일반 사용자”로 역할이 변경되면서 프로젝트에서 상세한 공정, 일감, 요구사항 관리들의 기능을 수행할 수 있다.
프로젝트의 어드민 관리 기능 (예. 메뉴, 사용자, 권한, 캘린더, 회의실)은 “관리자” 역할자만 변경이 가능했다.

  • ROLE_TEMPORARY_USER (임시 사용자)
    • 최초 회원가입하면 임시 사용자로써, 메일, 일정 관리등 커뮤니케이션 관련된 기능만 기본적으로 사용할 수 있음
    • COMMUNICATION_AUTHORITY : 메일 관리, 일정 관리
  • ROLE_USER (일반 사용자)
    • 관리자가 승인을 해주면, 일반 사용자로 변경이 되고 COMMUNICATION_AUTHORITY 외에 추가적으로 공정, 일감 관리 기능까지 수행 가능
    • COMMUNICATION_AUTHORITY, WORK_AUTHORITY (공정 관리), TASK_AUTHORITY (일감 관리)
  • ROLE_ADMIN (관리자)
    • 관리자의 경우, 프로젝트가 생성되면 프로젝트 관리자 계정이 자동으로 생성됨
    • 프로젝트 관리자 계정은 메뉴, 프로젝트 사용자, 권한 관리등 어드민성 기능을 수행할 수 있다.
    • 기본적으로는 일반 사용자의 권한을 사용할 수 있으며, 추가적으로 설정 관리까지 수행 가능하다.
    • CONFIG_AUTHORITY (설정 관리 - 메뉴, 사용자 관리 등등)

2. Spring Security 실습

이전에는 Role과 권한 설계방법에 대해서 알아보았다면, 이제는 Spring Security에서는 Role과 권한 적용을 위해서 어떤 클래스를 사용할 지 알아보자.
각 클래스별로 역할이 무엇인지 이해하고 있다면 구현하기 더 수월하다.

  • 빨간색으로 표시된 부분이 이번 실습과 연관된 클래스이다.

실습 프로젝트의 구조와 실습과 관련된 클래스에 대해서 알아보자.
Role과 권한 설계 예시에서 “프로젝트의 공정, 일감을 관리하는 시스템”의 내용을 사용하여 Unit Test 기반으로 실습을 진행한다.

  • 실습 프로젝트 Github 주소 : https://github.com/gregor77/start-spring-security
  • Spec : Java 8, Spring Boot, H2, Spring Web, Spring Data JPA, Lombok
  • 목표 : Role과 권한 설계 내용을 바탕으로 Spring security에서 사용자의 Role과 권한(Previlege)을 UserDetailsService를 사용해서 조회하는 실습을 진행해보자.

a. 프로젝트 설정

실습 프로젝트는 H2 DB를 사용하여 구동된다.

  • resources/db/data.sql
    • 어플리케이션 샘플 데이터(User, Role, Privilege)를 생성하는 insert 쿼리
    • 사용자 비밀번호는 bcrypt password encoder를 통해서 encoding 된 값이다.
  • resources/db/schema.sql
    • 어플리케이션을 수행하는데 필요한 테이블 생성 쿼리
  • resources/templates/login.html
    • email과 password 기반의 로그인 화면

b. User, Role, Privilege Entity 객체

  • User
    • 사용자 정보를 포함하는 entity 객체로 UserDetails의 구현체이다.
    • User와 Role은 N:N 관계이다.
  • Role
    • 시스템에서 관리하는 Role 정보를 저장하는 entity 객체
    • Role은 Privilege의 컨테이너로써 역할을 수행하기 때문에 하나의 Role은 여러 개의 권한을 포함한다.
    • Role과 Privilege의 관계는 N:N 관계이다.
    • Role과 Privilege는 “role_privilege” 매핑 테이블을 통해서 관리된다.
  • Privilege
    • 시스템에서 관리하는 권한 정보를 저장하는 entity 객체
    • Role과 Privilege의 관계는 N:N 관계이다.

c. UserDetails

  • 인증된 핵심 사용자 정보 (권한, 비밀번호, 사용자명, 각종 상태)를 제공하기 위한 interface이다.
  • 기존에 만들어진 시스템에 존재하는 User 클래스가 UserDetails의 구현체가 되면 된다.
  • 추가적으로 시스템에서 사용자 관리 시나리오에 따라 isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, isEnabled 구현하면 된다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    @Entity
    @Builder
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column
    private String name;

    @Column
    private String email;

    @Column
    private String password;

    @Column
    private String phoneNumber;

    @Transient
    private Collection<SimpleGrantedAuthority> authorities;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
    name = "user_role",
    joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
    inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")
    )
    private List<Role> roles;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    return this.authorities;
    }

    @Override
    public String getUsername() {
    return this.name;
    }

    @Override
    public boolean isAccountNonExpired() {
    return true;
    }

    @Override
    public boolean isAccountNonLocked() {
    return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
    return true;
    }

    @Override
    public boolean isEnabled() {
    return true;
    }
    }

d. UserDetailsService

  • username을 가지고 사용자 정보를 조회하고 session에 저장될 사용자 주체 정보인 UserDetails를 반환하는 Interface다.
  • 각 시스템에서는 커스터마이징을 위한 구현체 클래스를 생성해야 한다.
  • [참고] loadUserByUsername()에서 파라미터명을 username이 아니라 email로 변경한 이유는?
    • 샘플 프로젝트 로그인 화면에서 email과 password로 로그인하기 때문에, username 파라미터에 email 값으로 호출된다.
    • 로그인 화면 이동은 샘플 프로젝트 실행 후, 브라우저에서 “http://localhost:8080/login" 주소로 이동한다.
샘플 프로젝트 로그인 화면
  • loadUserByUsername()에서 하는 일
    • username을 가지고 사용자 정보를 조회
    • 사용자의 Role과 권한(Privilege)을 조회하여, SimpleGrantedAuthority 목록을 authorities에 세팅한다.
    • Authentication 내부 principal 객체에 UserDetails 객체가 저장된다.
    • Authentication 내부 authorities 객체에 사용자의 Role과 권한(Privilege) 정보가 저장된다.
    • UserDetails에 authorities가 세팅되어 있어야, API별 role이나 권한 체크를 진행할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserService userService;

public CustomUserDetailsService(UserService userService) {
this.userService = userService;
}

@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userService.getUser(email)
.orElseThrow(() -> new UsernameNotFoundException("User is not found. email=" + email));

user.setAuthorities(
Stream.concat(
getRoles(user.getRoles()).stream(),
getPrivileges(user.getRoles()).stream()
).collect(Collectors.toList())
);

return user;
}

private List<SimpleGrantedAuthority> getRoles(List<Role> roles) {
return roles.stream()
.map(Role::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}

private List<SimpleGrantedAuthority> getPrivileges(List<Role> roles) {
return roles.stream()
.flatMap(role -> role.getPrivileges().stream())
.map(privilege -> new SimpleGrantedAuthority(privilege.getName()))
.collect(Collectors.toList());
}
}

e. GrantedAuthority

  • GrantedAuthority는 ID, Password 기반 인증에서 UserDetailsService를 통해서 조회된다.
  • high-level authority라고 부르는 이유는, 어플리케이션 전반에 걸친 권한이기 때문이다. 따라서 특정 도메인에 특화된 권한을 의미하지는 않는다.
    • 즉, 시스템 레벨에서 필요한 권한이라고 생각하자.
    • 만약 specific한 수 천개의 role을 가지고 있었다면, 빠르게 메모리를 사용했을 뿐만 아니라, 사용자 인증을 하는데 많은 시간이 걸렸을 것이다.
  • 도메인 별로 구체적인 권한 체크가 필요한 경우에는, GrantedAuthority로 관리하지 않고, 각 API 별로 비지니스 권한을 체크한다.
  • @PreAuthorize나 @Secured 어노테이션을 사용하여 API나 서비스별로 시스템 권한 체크는 할 수 있다.
  • Authentication 클래스에 getAuthorities() 메소드를 통하여, 인증받은 사용자의 authorities를 조회할 수 있다.
    1
    2
    // Authentication 클래스
    Collection<? extends GrantedAuthority> getAuthorities()

f. SecurityContextHolder

  • SecurityContext를 보관하는 저장소
  • SecurityContext에는 Authentication 인스턴스가 저장된다.
  • Authentication에는 principal, credentials, authorities가 저장된다.

g. CustomUserDetailsServiceTest

spring test context를 띄워서 샘플 데이터를 대상으로 UserDetailsService가 잘 동작하는지 unit test를 수행한다.
각 사용자별로 Role, 권한(Privilege)가 잘 조회되는지 결과값을 확인하는 Assertion 테스트로 작성하였다.

  • Role 설명
    • 임시 사용자, 일반 사용자, 어드민 사용자 Role로 구성된다.
    • 피라미드로 생각하면, 아래서부터 위로 임시 사용자 < 일반 사용자 < 관리자 순이다.
    • 일반 사용자는 임시 사용자 role도 가지기 때문에, 임시 사용자의 권한도 모두 포함한다.
    • 관리자는 임시 사용자, 일반 사용자 role을 모두 가지기 때문에, 모든 권한을 가진다.

각 사용자별 테스트 검증 내용은 다음과 같다.

  • user1
    • 임시 사용자(ROLE_TEMPORARY_USER)로써, 의사소통 권한(COMMUNICATION_AUTHORITY)을 가지고 있는지 확인한다.
  • user2
    • 일반 사용자(ROLE_USER)로써 임시 사용자(ROLE_TEMPORARY_USER) Role도 가진다.
    • 의사소통 권한(COMMUNICATION_AUTHORITY), 공정 관리(WORK_AUTHORITY), 일감 관리(TASK_AUTHORITY) 권한을 가진다.
  • admin
    • 관리자 (ROLE_ADMIN)로써, 일반 사용자, 임시 사용자 Role을 모두 가진다.
    • 따라서 설정관리 권한(CONFIG_AUTHORITY)을 포함해서 모든 권한을 가진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = StartSecurityApplication.class)
class CustomUserDetailsServiceTest {
private static final String USER1_EMAIL = "user1@gmail.com";
private static final String USER2_EMAIL = "user2@gmail.com";
private static final String ADMIN_EMAIL = "admin@gmail.com";

@Autowired
private CustomUserDetailsService userDetailsService;

@Nested
class loadUserByUsername {
@Test
@DisplayName("throw UsernameNotFoundException when user not found with email")
void errorCase() {
UsernameNotFoundException error = assertThrows(UsernameNotFoundException.class,
() -> userDetailsService.loadUserByUsername("not-found@gmail.com"));

assertThat(error.getMessage()).isEqualTo("User is not found. email=not-found@gmail.com");
}

@Test
@DisplayName("given user1 is temporary user, when get role, then has temporary_user role and communication authority")
void checkAuthorityAsTemporaryUser() {
UserDetails user1 = userDetailsService.loadUserByUsername(USER1_EMAIL);

assertThat(user1.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.contains("ROLE_TEMPORARY_USER", "COMMUNICATION_AUTHORITY");
}

@Test
@DisplayName("given user2 is user, when get role, then has communication, user, temporary_user roles, and work, task authorities")
void checkAuthorityAsUser() {
UserDetails user2 = userDetailsService.loadUserByUsername(USER2_EMAIL);

assertThat(user2.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.contains("ROLE_USER", "ROLE_TEMPORARY_USER", "COMMUNICATION_AUTHORITY", "WORK_AUTHORITY", "TASK_AUTHORITY");
}

@Test
@DisplayName("given admin is admin user, when get role, then has all of roles and authorities")
void checkAuthorityAsAdminUser() {
UserDetails admin = userDetailsService.loadUserByUsername(ADMIN_EMAIL);

assertThat(admin.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.contains("ROLE_ADMIN", "ROLE_USER", "ROLE_TEMPORARY_USER", "COMMUNICATION_AUTHORITY", "WORK_AUTHORITY", "TASK_AUTHORITY", "CONFIG_AUTHORITY");
}
}
}

API 권한 테스트

샘플 프로젝트을 실행하여 로그인 후, 관리자 권한 사용자(admin@gmail.com)로 로그인했을때만, 관리자 권한 API가 동작하는 것을 확인할 수 있다.
테스트는 Postman을 사용해서 사용자 계정으로 로그인 후, 관리자 권한 API를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//UserController.java : 사용자 리소스 관리 Controller

@RequestMapping(value = "/v1/user")
@RestController
public class UserController {
private final UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

@GetMapping(value = "/{id}")
@PreAuthorize("hasRole('ADMIN')")
public User getUser(@PathVariable long id) {
return userService.getUser(id);
}

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(value = HttpStatus.CREATED)
public User createUser(@RequestBody User user) {
if (!userService.getUser(user.getEmail()).isPresent()) {
throw new IllegalArgumentException("not found with email=" + user.getEmail());
}

return userService.createUser(user);
}
}
  • API 권한체크 설명

    • API에서 @PreAuthorize 어노테이션을 사용해서 Role 체크를 수행하고 있다.
    • @PreAuthorize 어노테이션 내부에서 “hasRole” security expression을 사용하여 관리자(ROLE_ADMIN) 권한을 체크하고 있다.
    • hasRole 내부에서 defaultRolePrefix 가 “ROLE_”을 붙여서 검사하기 때문에, hasRole 문법의 인자에는 순수하게 Role명만 입력하면 된다.
  • 관리자 권한 API

    • 사용자 조회 API : GET, /v1/user/{id}
    • 사용자 등록 API : POST, /v1/user
  • 실패 사용자 계정

  • 성공 사용자 계정

1. 어드민 계정 로그인 2. (성공) 어드민 권한 사용자 조회 API 테스트 3. (실패) 일반사용자 권한 사용자 조회 API 테스트

마무리

이번 시간에 인증된 사용자 정보를 가리키는 UserDetails 인터페이스와 사용자 정보를 조회하는 UserDetailsService를 구현해 보았다.
그리고 인증된 사용자 정보가 SecurityContext 내부에 Authentication 객체에 저장되는 것을 확인할 수 있었다.

또한 사용자 인증 후, Authentication에 세팅된 authorities를 기반으로, @PreAuthorize 어노테이션에 선언된 API 권한 체크까지 정상적으로 동작하는 것을 확인할 수 있었다.

마지막으로 이번 블로그 포스트를 통해서 아래 내용은 꼭 이해하고 갔으면 한다.

  • Spring security 아키텍쳐에서 UserDetails, UserDetailsService에 대한 역할
  • 시스템에서 사용하는 사용자의 Role과 권한(Previlege) 설계 방법
  • UserDetails에 Role과 권한(Previlege)을 GrantedAuthority로 세팅하는 방법

참고

Share