본문 바로가기

Spring

@AuthenticationPrincipal - 현재 사용자 조회하기

해당 코드는 Github에 공개 되어 있습니다.

 

Spring Security에서는 Session에서 현재 사용자 정보를 조회할 수 있다. @AuthenticationPrincipal 애노태이션과 어댑터 패턴을 이용해서 이를 효율적으로 조회해보도록 하자.

 

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User)authentication.getPrincipal();

 

이는 @AuthenticationPrincipal 로도 구현 가능하고 이 결과는 UserDetailsService를 구현한 AccountService에서 반드시 상속받아야 하는 loadUserByUsername 메서드의 리턴된 결과이다.

 

@Service
public class AccountService implements UserDetailsService {
    ...
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return new User(account.getEmail(), account.getPassword(), authorities(account.getAccountRole()));
    }
    ...
}

 

Spring Security에는 리턴 타입인 UserDetails를 구현한 구현체인 User 클래스가 있고, 내가 정의한 Entity를 UserDetails가 이해하기 위해서는 Account 객체를 통해 User객체를 생성한다. (Account : email, password, role -> User : username, password, grantType을 인자로 생성)

 

User 객체에는 3가지를 제외한 Account의 다른 정보를 담지 않고 있기 때문에 현재 사용자 정보를 받아올 때 Account 객체의 다른 필드 값을 참조해야 하는 상황에서 다시 username을 통해 DB에서 Account를 불러와야 한다.(username은 unique 해야 함)

 

/*흔하게 CRUD의 RUD를 구현할 때 id 값을 바탕으로 Account 객체를 불러와서 구현한다.
이는 pk 특성상 unique 하기 때문인데 username을 unique 하게 하고 똑같이 구현하면 되지 않을까 라는 생각을 했다.*/

 

관리자의 경우 @AuthenticationPrincipal 애노태이션을 사용해서 해당 사용자를 불러 올 수 없다. (로그인 한 사용자가 아니므로) 따라서 @PathVariable 애노태이션을 사용해서 endpoint에 접근하는 게 불가피한데(@RequestParam 은 다른 GET 매핑과 URL 이 같아 Controller 레벨에서 분기하는 것이 아닌 메서드 레벨에서 분기해야 함) 관리자의 권한이 가장 높기 때문에 보안상 문제가 되지 않는다.

 

하지만 개인 사용자의 경우 자신의 정보를 조회, 수정, 삭제 할 때 이와 같은 방법으로 접근한다면 관리자와 같은 방법으로 접근한다고 볼 수 있고 이것이 의미하는 것은 일반 사용자는 관리자가 아니기 때문에 인가 절차를 강화해야 한다는 것이다. (접근할 때마다 해당 사용자가 맞는지 확인)

 

따라서 이런 과정을 막기 위해 아예 접근할 필요가 없게 수정, 조회, 삭제를 할때 /{id} 가아니라 현재 사용자를 불러와서 해결한 방법인데, 여기에도 문제가 있다. 이는 단순히 Account -> User -> Account의 문제가 아니라 조회, 삭제, 수정할 때마다 DB에 쿼리를 날린다는 것... //Admin 혼자서 사용자를 조회할 때 쿼리가 날아가는 것과는 다른 문제임

 

/*단순히 로그인했니? 안 했니? 는 User 객체로도 판단이 가능하지만 세부 내용을 알 수 없고 다른 비즈니스 로직에서 Account 객체를 필드로 가지고 있어서 참조해야 할 때 ( 상품 구매한 사람) Account 객체를 불러오는 것이 불가피함*/

 

이를 해결하기 위해 Account 객체를 UserDetails 가 이해할 수 있게 만들어서 애초에 loadUserByUsername 메서드에서 User 객체가 아닌 Account를 리턴하는 것이다!! (정확하게는 User를 상속받는 것 -> Account is a kind of User 관계)

이를 객체지향스럽게 구현하는 Best practice는 어댑터 패턴이다.

 

Account Entity에서 직접 User를 상속받는 것이 아니라 AccountAdapter 클래스를 만들어 User를 상속받고
AccountAdapter가 Account를 참조 ( has a 관계) 하면

 

public class AccountAdapter extends User {

    private Account account;

    public AccountAdapter(Account account) {
        super(account.getEmail(), account.getPassword(), authorities(account.getAccountRole()));
        this.account = account;
    }

    private static Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
        return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))
                .collect(Collectors.toSet());
    }

    public Account getAccount() {
        return account;
    }
}

 

loadUserByUsername 메서드에서는 AccountAdapter를 리턴하게 되고(AccountAdapter -> User -> UserDetails)

 

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return new AccountAdapter(account); //변경부분
    }

 

 현재 사용자를 불러오면 AccountAdapter를 받아와 어댑터 안에 Account를 가져올 수 있고, DB에 쿼리를 날리지 않고 현재 사용자를 Account 객체로 받아 올 수 있다!!

 

/* 변경전 코드 */ 
    @GetMapping
    public ResponseEntity getAccount(@AuthenticationPrincipal User currentUser) {
        Optional<Account> account = accountService.getAccount(currentUser.getUsername());
        //이부분에서 DB에 쿼리를 날림
        
        if (account.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok(account);
    }

 

/* 변경 후 코드 */
    @GetMapping
    public ResponseEntity getAccount(@AuthenticationPrincipal AccountAdapter accountAdapter) {
        Account account = accountAdapter.getAccount();

        return ResponseEntity.ok(account);
    }

 

SPEL 을 통해서 더 간략하게 줄일 수 있고,

 

/* SPEL */
    @GetMapping
    public ResponseEntity getAccount(@AuthenticationPrincipal(expression = "account") Account account) {
        return ResponseEntity.ok(account);
    }

 

 

@AuthenticationPrincipal(expression = "account") 을 좀더 명시성과 간결함을 위해

새롭게 @CurrentUser 애노태이션을 정의하면

(하지만 이처럼 단순히 오버라이드 해서 커스텀한 애노태이션들이 많으면 유지보수 관점에서 혼란을 야기시킬 수 있음을 알고있자)

 

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "account")
public @interface CurrentUser {
}

 

깔끔하게 현재사용자를 받아올 수 있다.

 

    @GetMapping
    public ResponseEntity getAccount(@CurrentUser Account account) {
        return ResponseEntity.ok(account);
    }

 

 

More

  • JWT를 사용할 경우에는 Session 이 아니라 토큰에서 현재 사용자를 받아와야겠지
  • 어댑터 패턴이 어떻게 객체지향을~
  • 어떤 메커니즘으로 현재 사용자를 불러올까 ( Security의 정교함을 더 공부하자)