개발하는 자몽

[Spring Security] 역할/권한 구현하기 본문

Java/Spring

[Spring Security] 역할/권한 구현하기

jaamong 2024. 6. 14. 20:00

Notice  Spring Boot 3.2.5, Spring Security 6.2.5 기반으로 작성한 글입니다.

 

지난 포스팅에서 이어집니다.

[Spring Security]토큰 기반 로그인/로그아웃 구현하기

 

 

역할과 권한

사용자의 역할(role)과 권한(permission/authority)을 구현하여 기능 접근에 제약을 둘 수 있다. 만일 권한이 없는 기능에 접근하면 403 Forbidden이 발생한다. 역할 및 권한 구현 시 다음과 같은 것들을 고려해야 한다.

 

  • 한 역할은 여러 개의 권한을 가질 수 있다.
  • 여러 명의 사용자는 여러 개의 역할을 가질 수 있다. → 다대다 관계

역할을 하나의 Entity로 두어 구현할 수 있지만, 이 경우에 고려할 것이 많아진다.

 

  • 다대다 관계 매핑 → `@ManyToMany`
    • 중간 테이블을 두어 일대다, 다대일로 풀어내기

다대다 관계를 중간 테이블로 해결하는 것이 권장되지만, 이렇게 하면 역할과 관련된 로직이 복잡해진다. 그래서 나는 역할을 Enum 클래스로 구현했다. 마찬가지로 권한도 Enum 클래스로 구현한다. 

Enum 클래스로 구현해도 역할은 여러 권한을 가질 수 있고, 사용자도 여러 역할을 갖도록 구현할 수 있다. 여기에서는 사용자가 한 역할만 갖고 있도록 구현했으나, 권한을 역할에 따라 부여하도록 구현했으므로 문제없다. 만일 권한을 세부적으로 정하지 않고 역할로만 기능 접근을 설정한다면 사용자가 여러 역할을 갖도록 하는 것이 좋다. 

 

 

구현하기

Permission Enum class

어떤 기능을 수행할 수 있는지 나타내는 Permission(권한) 클래스이다. 

@Getter
public enum Permission {

    READ("READ_AUTHORITY"),
    CREATE("CREATE_AUTHORITY"),
    UPDATE("UPDATE_AUTHORITY"),
    DELETE("DELETE_AUTHORITY");

    private final String permission;

    Permission(String permission) {
        this.permission = permission;
    }
}

 

Role Enum class

각 역할 별로 어떤 권한이 있는지 나타낸다.

@Getter
public enum Role {
    ADMIN(Set.of(
            READ,  //Permission enum class
            CREATE,
            UPDATE,
            DELETE
    )),
    MANAGER(Set.of(
            READ,
            CREATE,
            UPDATE
    )),
    USER(Set.of(
            READ
    ));

    private final Set<Permission> permissions;  //Permission enum class

    Role(Set<Permission> permissions) {
        this.permissions = permissions;
    }

    public List<SimpleGrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = getPermissions()
                .stream()
                .map(permission -> new SimpleGrantedAuthority(permission.getPermission()))
                .collect(Collectors.toList());

        authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name()));

        return authorities;
    }
}

`getAuthorities()`는 역할이 어떤 권한(permission)을 가지고 있는지 반환하는 메서드이다. 이는 `UserDetailsService` 구현체의 `loadUserByUsername`에서 `User` 객체를 생성할 때 사용자가 어떤 권한을 가지고 있는지 반환할 때 사용될 수 있다.

...

@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        return new User( //사용자 정보를 담는 객체에 권한도 포함
                user.getUsername(),
                user.getPassword(),
                mapAuthorities(user.getRole()) 
        );
    }

    private Collection<? extends GrantedAuthority> mapAuthorities(Role role) {
        return role.getAuthorities();  //here
    }
}

 

User Entity class

이렇게 구현된 역할은 `User` 엔티티의 필드가 된다. DB에 저장할 때는 문자열로 저장되도록 설정한다.

...

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

		...

    @Enumerated(EnumType.STRING)
    private Role role;

    @Builder
    public UserEntity(String username, String password, Role role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

}

 

참고로 `User` 엔티티가 여러 역할(Role Enum class)를 갖도록 할 때는 아래처럼 하면 되지 않을까?

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "users")
public class UserEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
	...

    @Enumerated(EnumType.STRING)
    private Set<Role> roles = new HashSet<>();

    // Constructors, getters, and setters
}

 

다시 돌아와서.. 프로젝트 요구사항에 역할 변경 기능이 없어서 별도로 구현하지 않았으나, 필요하다면 `setter`처럼 구현하면 될 것 같다. (사용자가 여러 개의 역할을 가질 수 있으면 역할 추가, 삭제로 나뉠 수 있다)

이 부분은 프로젝트 마다 다르므로, 프로젝트 요구사항을 바탕으로 설계할 때 적절하게 코드를 작성하자.

 

이제 `User` 엔티티의 로직과 별개로 이를 수행할 서비스 계층의 비즈니스 로직이 필요하다.

 

이번에는 해당 역할 및 권한을 가진 사용자만 엔드포인트에 접근할 수 있도록 설정한다.

 

Spring Security Configuration

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final String[] PERMIT_ALL_URL = {...};

    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                ...
                .authorizeHttpRequests(req -> req
                                .requestMatchers(PERMIT_ALL_URL).permitAll()
                                .requestMatchers(HttpMethod.GET, "/api/boards/**").hasAnyAuthority(READ.name())
                                .requestMatchers(HttpMethod.POST, "/api/boards/**").hasAnyAuthority(CREATE.name())
                                .requestMatchers(HttpMethod.PUT, "/api/boards/**").hasAnyAuthority(UPDATE.name())
                                .requestMatchers(HttpMethod.DELETE, "/api/boards/**").hasAnyAuthority(DELETE.name())
                                .anyRequest()
                                .authenticated()
                )
                ...;

        return http.build();
    }
}

`requestMatchers`에 HTTP 메소드와 엔드포인트에 따라 어떤 권한(Authority)이 필요한지 `hasAnyAuthority`를 사용하여 설정한다.

이렇게 security configuration 클래스에서 설정하는 방식 외에 컨트롤러에서 설정할 수 있는 방법도 있다. 

 

Controller

@RestController
@RequestMapping("/api/boards")
@RequiredArgsConstructor
public class BoardController {

    private final BoardService boardService;

    @GetMapping
    @PreAuthorize("hasAuthority('READ_AUTHORITY')")
    public ResponseEntity<..> readAllBoard() {
        // 필요 로직 실행
        return ResponseEntity.ok(..);
    }

    ...
}

모든 게시물을 조회할 수 있는 `GET /api/boards` URL이 매핑된 메서드는 READ_AUTHORITY가 없으면 접근할 수 없도록 `@PreAuthorize`를 사용하여 설정했다. 이렇게 각 메서드 별로 필요한 권한을 지정해 줄 수 있다.

해당 애노테이션을 사용할 때는 `@Configuration`이 적용된 security configuration 클래스에 `@EnabledMethodSecurity` 애노테이션을 추가해야 한다.

@Configuration
@EnableMethodSecurity  //add this
public class SecurityConfig {
	...
}

 

또한 컨트롤러에 적용했으면 configuration 클래스에 작성한 `requestMatcher` 부분은 제거해도 된다.

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final String[] PERMIT_ALL_URL = {...};

    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                ...
                .authorizeHttpRequests(req -> req
                                .requestMatchers(PERMIT_ALL_URL).permitAll()
//                        .requestMatchers(HttpMethod.GET, "/api/boards/**").hasAnyAuthority(READ.name())
//                        .requestMatchers(HttpMethod.POST, "/api/boards/**").hasAnyAuthority(CREATE.name())
//                        .requestMatchers(HttpMethod.PUT, "/api/boards/**").hasAnyAuthority(UPDATE.name())
//                        .requestMatchers(HttpMethod.DELETE, "/api/boards/**").hasAnyAuthority(DELETE.name())
                                .anyRequest()
                                .authenticated()
                )
                ...;

        return http.build();
    }
}

 

@EnableMethodSecurity

`@Configuration` 클래스에 해당 어노테이션을 달면 Spring이 관리하는 클래스 또는 메소드에 `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, `@PostFilter` 어노테이션을 달아서 입력 매개변수와 반환값을 포함한 메서드 호출을 승인(authorize)할 수 있다.

 

예를 들어 특정 컨트롤러에 관리자(ADMIN) 역할을 지닌 사용자만 접근하도록 하고 싶다면 아래와 같이 작성할 수 있다.

@RestController
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
	...
}

 

관리자 역할을 지닌 사용자 중에서도 특정 권한을 갖고 있지 않으면 메서드에 접근하지 못하도록 할 수 있다.

@RestController
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
	
	...
	
	@PostMapping
	@PreAuthorize("hasAuthority('ADMIN_CREATE')")
	public void create(...) {
		...
	}
}

 

Note request-level(declared in a config class) 인증과의 차이점은 메서드 방식이 좀 더 세밀하게 인증 형식을 설정할 수 있다는 정도다. 애노테이션 인증 관련 자세한 내용은 여기 링크를 참고!

 

 

 

 

 

🔖참고

Comments