본문 바로가기
Spring

Spring Security. UserDetails / UserDetailsService란?

by 리승우 2022. 10. 7.

교육자료를 보면서 배우던 와중, 그동안 이게 일반적인 변수라고 생각했었다. 

하지만 단단히 착각했었다. 

아래 내용들은 모두 인터페이스였다. 

내 시간!!

 

[요약내용]

UserDetails에 어카운트 계정을 넣어주기 위해서 UserDetailsService를 사용한다.

 

UserDetails 란?

Spring Security에서 사용자의 정보를 담는 인터페이스이다.

Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드들은 아래와 같다.

 

메소드 리턴 타입 설명 기본값
getAuthorities() Collection<? extends GrantedAuthority> 계정의 권한 목록을 리턴  
getPassword() String 계정의 비밀번호를 리턴  
getUsername() String 계정의 고유한 값을 리턴
( ex : DB PK값, 중복이 없는 이메일 값 )
 
isAccountNonExpired() boolean 계정의 만료 여부 리턴 true ( 만료 안됨 )
isAccountNonLocked() boolean 계정의 잠김 여부 리턴 true ( 잠기지 않음 )
isCredentialsNonExpired() boolean 비밀번호 만료 여부 리턴 true ( 만료 안됨 )
isEnabled() boolean 계정의 활성화 여부 리턴 true ( 활성화 됨 )

 

여기서 잘 봐야하는 메서드가 getUsername()이다.

username은 계정의 고유한 값인데 다른 블로그들을 보니까 다들 email( 로그인용 아이디 )을 넘겨준다고 하지만 email( 로그인용 아이디 )은 SSO 같은 서버를 만들게 되면 정책에 따라서 중복이 될 수도 있기에 나와 같은 경우는 보통 DB에서 User Table에 PK 값을 넘겨준다.

 

 

CustomUserDetails 구현하기

대부분의 경우 Spring Security의 기본 UserDetails로는 실무에서 필요한 정보를 모두 담을 수 없기에 아래와 같은 CustomUserDetails를 구현하여 사용한다.

@Getter
public class CustomUserDetails implements UserDetails, Serializable {

    private static final long serialVersionUID = 174726374856727L;

    private String id;	// DB에서 PK 값
    private String loginId;		// 로그인용 ID 값
    private String password;	// 비밀번호
    private String email;	//이메일
    private boolean emailVerified;	//이메일 인증 여부
    private boolean locked;	//계정 잠김 여부
    private String nickname;	//닉네임
    private Collection<GrantedAuthority> authorities;	//권한 목록
	
    
    /**
    * 해당 유저의 권한 목록
    */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
           return authorities;
    }

	/**
    * 비밀번호
    */
	@Override
    public String getPassword() {
        return password;
    }


	/**
    * PK값
    */
    @Override
    public String getUsername() {
        return id;
    }

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

    /**
     * 계정 잠김 여부
     * true : 잠기지 않음
     * false : 잠김
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return locked;
    }

    /**
     * 비밀번호 만료 여부
     * true : 만료 안됨
     * false : 만료
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }


    /**
     * 사용자 활성화 여부
     * ture : 활성화
     * false : 비활성화
     * @return
     */
    @Override
    public boolean isEnabled() {
        //이메일이 인증되어 있고 계정이 잠겨있지 않으면 true
        return (emailVerified && !locked);
    }

}

 

UserDetailsService 란?

Spring Security에서 유저의 정보를 가져오는 인터페이스이다.

Spring Security에서 유저의 정보를 불러오기 위해서 구현해야하는 인터페이스로 기본 오버라이드 메서드는 아래와 같다.

메소드 리턴 타입 설명
loadUserByUsername UserDetails 유저의 정보를 불러와서 UserDetails로 리턴

 

 

UserDetailsServiceImpl 구현하기

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserInfoRepository userInfoRepository;

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        UserInfo userInfo = userInfoRepository.findByLoginId(loginId).orElseThrow(() -> new UsernameNotFoundException("not found loginId : " + loginId));

        CustomUserDetails customUserDetails = new CustomUserDetails();
        // 값 세팅 로직 시작
        ...
        // 값 세팅 로직 끝
        return customUserDetails;
    }
}

 

아마 대부분의 프로젝트에서 로그인 화면에서는 아래와 같은 HTML로 login용 ID와 Password를 함께 넘길 것이다.

<form action="/login" method="post">
  <input type="text" id="loginId" name="loginId" autofocus="autofocus" /> <br />
  <input type="password" id="password" name="password" /> <br />
  <input type="submit" value="Log in" />
</form>

아래에 있는 시큐리티 설정을 해준다면

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin()
                .usernameParameter("loginId")
                .passwordParameter("password")
	}
    
}

Spring Security에서 loginId를 넘겨줄 것이고 loadUserByUsername 에서 해당 값으로 DB를 조회 후 저장되어 있는 유저 정보를 가져온다.

그 후에 원하는 형태로 CustomUserDetails를 세팅해준 후 리턴해주면 Spring Security에서는 해당 유저의 정보를 조회할 때에는 CustomUserDetails에 세팅된 값으로 조회를 한 후 로직을 처리해준다.

 

 

SecurityContextHolder에서 UserDetails 불러오기 

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserDetails userDetails = (UserDetails)principal;

String username = userDetails.getUsername();
String password = userDetails.getPassword();

기본적으로 Spring Security의 principal 객체는 Object 형태로 UserDetails를 형변환 해야 한다.

 

 

1. UserDetailsService

UserDetailsService는 DaoAuthenticationProvider와 협력하는 인터페이스입니다.

DaoAuthenticationProvider는 요청받은 유저의 ID, Password와 저장된 ID, Password의 검증하는 책임을 갖고있는데요. 그래서 이 녀석은 저장된 ID, Password를 갖고오기 위하여 UserDetailsService와 협력합니다.

출처 - spring seuciry 레퍼런스
그림을 통해 DaoAuthenticationProvider가 UserDetailsService와 협력하는 것을 볼 수 있네요:)

그렇다면 UserDetailsService는 어떤 메세지를 정의 했는지 인터페이스를 한번 살펴보시죠.

public interface UserDetailsService {

	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

파라미터로 username(유저를 식별하는 수 있는 ID)을 받고 리턴값으로 UserDetails를 돌려주고 있네요.

DaoAuthenticationProvider에서는 돌려받은 UserDetails 객체를 가지고 최종적으로는 UsernamePasswordAuthentication 객체를 만들어 ProviderManager에게 돌려줍니다.

  • UserDetails 인터페이스
public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	String getPassword();

	String getUsername();

	boolean isAccountNonExpired();

	boolean isAccountNonLocked();

	boolean isCredentialsNonExpired();

	boolean isEnabled();

}

Spring Security 에서는 UserDetails의 User라는 구현체를 기본으로 제공하고 있네요.
저는 이 기본 구현체를 이용하여 UserDetailsService를 간단히 구현해보겠습니다.

2. UserDetailsService 구현

저는 멤버를 데이터베이스에 미리 저장하고 저장된 것을 꺼내오는 방식으로 구현해보았습니다.

  • 사용 기술
    DB - H2,
    Data Access - Data Jpa

우선 Member를 위한 package를 만들어서 엔티티, 레파지토리, UserDetailsService구현체를 넣어주었습니다. (패키지 구성은 간편하게 했어요.)

그리곤 코드는 이렇게.. 간략하게 쓱싹

  • Member Entity
@Entity
@Getter
@Setter
@ToString
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;

    private String memberId;

    private String password;

}
  • Member Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByMemberId(String memberId);
}
  • UserDetailsService 구현체
@RequiredArgsConstructor
@Component
@Slf4j
public class MyUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByMemberId(username)
                .orElseThrow(() -> new UsernameNotFoundException("Could not found user" + username));

        log.info("Success find member {}", member);

        return User.builder()
                .username(member.getMemberId())
                .password(passwordEncoder.encode(member.getPassword()))
                .roles("USER")
                .build();
    }

}

주의 하실 점은 User객체를 만드는 코드에 PasswordEncoder를 사용하여 DB에 저장된 원래 패스워드를 암호화하도록 코드가 짜여져 있는데요. 원래는 DB에 저장된 값 자체가 암호화 되어있어야합니다.
즉. 이미 넣을때 passwordEncoder를 통해 암호화된 문자열이 들어가있어야 하는데 편의상 저는 User객체를 리턴해줄때 암호화를 하였습니다.

참고 사항으로 SpringSecurity는 PasswordEncoder 사용을 강제합니다.
실제로 DaoAuthenticationProvider 내부 코드에서도 비밀번호를 원문 텍스트로 비교하는 것이 아닌 passwordEncoder의 matches 메서드를 사용해 비교합니다.
보안상 Spring Security에서는 패스워드의 암호화 방식이 단방향 암호화이며 쉽게 복호화하여 사용자의 패스워드를 볼 수 없게 만들어놨고 암호화되어 저장된 패스워드 문자열을 matches라는 메서드를 통해 비교할 수 있도록 해놨습니다.

그리고 마지막으로 User는 미리 이렇게 data.sql을 이용하여 하나 넣어두었습니다.

  • data.sql 에 작성한 쿼리
insert into member(id, member_id, password) values('0','incheol', 'password');

자, 이제 테스트를 한번 해보고 정상적으로 UserDetailsService 구현체를 통해 로그인이 되는지 확인해보겠습니다.

MyUserDetailsService 에 찍어둔 로그가 출력이 되었고
미리 등록해둔 AuthenticationSuccessHandler가 콘솔로그를 잘 찍혔음을 확인했습니다.

다음에는 Test 코드를 활용하여 인증된 유저가 api 호출을 정상적으로 할 수 있는지 확인해보도록 하겠습니다:)

댓글