본문 바로가기
Spring

Spring. @AuthenticationPrincipal이란?

by 리승우 2022. 10. 7.

이전 방식

@LoginUser 커스텀 어노테이션을 이용해 로그인 세션 정보를 받아왔으나 null 일 때에도 세션에 정보가 담기는 에러가 있었다. 따라서 @AuthenticationPrincipal 애노테이션을 이용해 로그인 세션 정보를 받아오는 방법으로 변경했다.

아직 미해결한 점이 있다. user 엔티티가 아니라 userDto로 감싸려고 하는데 로그인 정보가 없을 때, 즉 비로그인 사용자일 때에는 null인데 이 null처리가 까다로웠다.

  • 그래서 CustomUserDetails을 상속받은 UserAdapter 에 따로 getUserDto 메서드를 만들어 Controller에서 UserDetails가 기본적으로 제공하는 정보 외 로그인 유저 정보 (닉네임 등)를 getUserDto.getXXX 형식으로 받기로 했다.
    조금 번거롭지만 현재 먼지투성이가 된 내가 할 수 있는 최선의 방법이었다,,,,,,,

로그인 사용자의 정보가 필요할 때 매번 서버에 요청을 보내 DB에 접근해서 데이터를 가져오는 것은 비효율적이다.

따라서 한번 인증된 사용자 정보를 세션에 담아놓고 세션이 유지되는 동안 사용자 객체를 DB로 접근하는 방법 없이 바로 사용할 수 있도록 한다.

Spring Security에서는 해당 정보를 SecurityContextHolder 내부의 SecurityContext에 Authentication 객체로 저장해두고 있으며 이를 참조하는 방법은 크게 3가지가 있다.

  • 컨트롤러에서 Principal 객체를 주입받아 사용
    • 가장 간단한 방법
      Spring Security가 제공하는 SecurityContextHolder의 Principal 객체가 아닌 자바에 정의돼있는 Principal 객체를 바인딩해주는 것이므로 사용할 수 있는 메소드가 getName() 뿐이다.
  • 컨트롤러에서 @AuthenticationPrincipal 선언하여 엔티티 객체 받아오기
    • 엔티티에 있는 모든 필드 참조 가능
  • 컨트롤러에서 @AuthenticationPrincipal 선언하여 엔티티의 어댑터 객체 받아오기(가장 권장)
    • 엔티티 객체를 필드로 갖고 있는 어댑터 클래스(DTO) 생성하여 회원 객체(User) 상속
    • 해당 어댑터 클래스의 엔티티 객체는 DB의 회원 객체의 정보를 담고 있어야 한다
    • UserDetailsService의 loadByUsername()에서 어댑터 클래스를 반환하도록 수정

스프링 시큐리티에서는 세션에 현재 사용자 정보를 다음과 같이 조회할 수 있다.

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

principal 객체는 자바 표준 객체이며 우리가 받을 있는 정보는 name 뿐이다.
name 말고도 다양한 정보를 얻기 위해 @AuthenticationPrincipal 애노테이션과 어댑터 패턴을 적용해 사용자 세션 정보를 사용할 수 있다.

@AuthenticationPrincipal

세션 정보 UserDetails에 접근할 수 있는 어노테이션

@AuthenticationPrincipal  UserDetails 타입을 가지고 있음 -> UserDetails 타입을 구현한 PrincipalDetails 클래스를 받아 User object를 얻는다

  • userDetails(PrincipalDetails 타입).getUser()

따라서 로그인 세션 정보가 필요한 컨트롤러에서 매번 @AuthenticationPrincipal로 세션 정보를 받아서 사용한다.

  • @AuthenticationPrincipal UserAdapter 타입
  • 로그인 세션 정보를 애노테이션으로 간편하게 받을 수 있다
  • UserDetailsService에서 Return한 객체를 파라미터로 직접 받아 사용할 수 있다.
  • name 뿐만 아니라 다양한 정보를 받을 수 있다
  • 중복 코드를 효율적으로 줄일 수 있다

CustomUserDetails

  • UserDetails, OAuth2User, 즉 기존 폼 로그인과 OAuth 2.0 로그인을 따로 처리하지 않고 함께 UserDetails로 묶어 처리할 수 있게 코드를 리팩토링했다.CustomUserDetails implements UserDetails, OAuth2User
package com.jy.config.auth;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import com.jy.domain.user.User;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
public class CustomUserDetails implements UserDetails, OAuth2User {
	private User user;
	private Map<String, Object> attribute;
	
	/* 일반 로그인 생성자 */
	public CustomUserDetails(User user) {
		this.user = user;
	}
	
	/* OAuth2 로그인 사용자 */
	public CustomUserDetails(User user, Map<String, Object> attributes) {
		this.user = user;
		this.attribute = attributes;
	}
	
	/* 유저의 권한 목록, 권한 반환*/
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collect = new ArrayList<>();
		collect.add(new GrantedAuthority() {
			
			@Override
			public String getAuthority() {
				return user.getRole().getValue();
			}
		});
		return collect;
	}

	@Override
	public String getPassword() {
		return user.getPassword();
	}

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

//	public String getNickname() {
//		return user.getNickname();
//	}
	/* 계정 만료 여부
	 * true :  만료 안됨
	 * false : 만료
	 */
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	/* 계정 잠김 여부
	 * true : 잠기지 않음
	 * false : 잠김
	 */
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}
	
	/* 비밀번호 만료 여부
	 * true : 만료 안 됨
	 * false : 만료
	 */

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

	/* 사용자 활성화 여부
	 * true : 활성화 됨
	 * false : 활성화 안 됨
	 */
	@Override
	public boolean isEnabled() {
		return true;
	}

	/* OAuth2User 타입 오버라이딩 */
	@Override
	public Map<String, Object> getAttributes() {
		return null;
	}

	@Override
	public String getName() {
		return null;
	}
}
  • 처음에 getNickname 하려고 했지만 직렬화 관련 에러가 났다. local class incompatible 그래서 오버라이딩할 수 있는 메소드만 오버라이딩했다.
    • UserDetails 타입 오버라이딩
    • OAuth2User 타입 오버라이딩 (getAttributes(), getName())
  • 마찬가지로 OAuth2User 생성자도 만들었다.
	public CustomUserDetails(User user, Map<String, Object> attributes) {
		this.user = user;
		this.attribute = attributes;
	}

UserAdapter

  • User 정보를 직접 받는 게 아니라 어댑터 패턴을 이용해 받는다.
    • 정보 객체로 사용되는 객체는 UserDetails을 상속받아야 한다.
      (loadUserByUsername 메서드의 반환 타입이 UserDetails이기 때문)
    • 도메인 객체는 특정 기술에 종속되지 않도록 개발해야 한다는 것이 Best Practice
@Getter
public class UserAdapter extends CustomUserDetails{

    private Member member;
    private Map<String, Object> attributes;

    public UserAdapter(Member member){
        super(member);
        this.member = member;
    }

    public UserAdapter(Member member, Map<String, Object> attributes){
        super(member, attributes);
        this.member = member;
        this.attributes = attributes;
    }
}

CustomUserDetailsService

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

    private final MemberRepository memberRepository;
    private final HttpSession session;

    /** username이 DB에 존재하는지 확인 **/
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username).orElseThrow(() ->
                new UsernameNotFoundException("사용자가 존재하지 않습니다."));

        /** 시큐리티 세션에 유저 정보 저장**/
        return new UserAdapter(member);
    }
}

UserDetails를 상속 받은 UserAdapter을 통해 커스텀한 Principal를 사용할 수 있다.

인증을 담당하는 loadUserByUsername에서 Principal(UserDetails) 대신 위에서 만든 UserAdapter을 반환한다.
loadUserByUsername메서드의 반환되는 타입을 변경하기 위해 Principal(UserDetails)을 커스텀한 것이다.
(스프링 세션을 사용하면 첫 로그인 시에만 loadUserByUsername메서드가 호출된다.
JWT로 구현하였다면 매 요청마다 loadUserByUsername메서드가 호출된다.)

댓글