Spring security/01.Security

09. UserDetails와 UserDetailsService 커스터마이징

  • -

이번 포스트에서는 UserDetails와 UserDetailsService의 커스터마이징 필요와 방법에 대해 살펴보자.

 

UserDetails 커스터마이징

 

기본 UserDetails의 문제점

이제 로그인/로그아웃 관리 및 secured resource에 대한 접근 관리를 spring security가 처리해주니 훨씬 더 비지니스 로직에 집중할 수 있게 되었다.

하지만 여전히 아쉬움이 남는데 바로 사용자 계정 정보를 담고있는 UserDetails에는 단지 사용자의 아이디와 비밀번호, role 정보등 만 담긴다는 점이다. 결과적으로 로그인에 성공했을 때 "admin 님 반갑습니다."의 형태로만 정보를 출력할 수 있다. ID 보다는 이름을 활용하여 "관리자님 반갑습니다."의 형태가 훨씬 부드럽지만 기본 UserDetails로는 관련 내용을 처리하기 어렵다. 어떻게 이 부분을 처리할 수 있을까?

이를 위해 기존의 Entity인 User를 우리 입맛에 맞게 바꿔보고 인증을 위해 사용하는 UserDetails, UserDetailsService를 확장하는 방법을 알아보자.

 

Entity 수정

일단 User Entity에는 uno와 userId 컬럼을 추가한다. uno는 내부적으로 사용되는 P.K이고 userId는 사용자가 인지하는 id이다. 이제 username은 사용자의 실제 이름으로 사용하자. userId는 반드시 unique 해야 한다.

@Entity
@Table(name = "users")
public class User {
UserDetails d;
    
    @Id   
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long uno;             // 내부적으로 사용되는 P.K

    @Column(unique = true, nullable = false)
    private String userId; // 사용자가 인지하는 userId: admin

    @Column(nullable = false)
    private String username; // 사용자의 실제 이름: 관리자
    
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "uno")
    private Set<Authority> authorities = new HashSet<>();
    ...
}

Authority에서도 username을 uno로 변경해주자.

 

UserRepository

다음으로 UserRepository를 추가한다. 기존에는 미리 저장된 sql을 이용해서 알아서 조회해줬지만 우리가 원하는 컬럼, 테이블을 이용하기위해서는 직접 처리해야 한다. 사용자가 입력한 userId로 검색이 되어야 한다는 점을 생각하고 findByUserId 메서드를 추가하자.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUserId(String userId);
}

 

CustomUserDetails implements UserDetails

우리가 필요한 정보를 UserDetails에 추가하기 위해서는 UserDetails를 구현해서 확장하면 된다.

다음은 UserDetails에 선언된 abstract method 들이다.

 메소드 명 리턴 타입 설명 
 getAuthorities()  Collection<? extends   GrantedAuthority>  계정의 권한 목록 리턴
 getPassword()  String  계정의 비밀번호 리턴
 getUsername()  String  계정의 이름 리턴(Primary Key에 해당하는 정보)
 isAccountNonExpired()  boolean  계정이 만료되지 않았는지 리턴 (true: 만료안됨)
 isAccountNonLocked()  boolean  계정이 잠겨있지 않았는지 리턴 (true: 잠기지 않음)
 isCredentialNonExpired()  boolean  비밀번호가 만료되지 않았는지 리턴 (true: 만료안됨)
 isEnabled()  boolean  계정이 활성화(사용가능)인지 리턴 (true: 활성화)

UserDetails를 구현할 때는 기본적으로 위 메서드에 대응하는 field를 선언하고 추가로 관리하고 싶은 field를 선언하면 된다. 그리고 lombok의 @Getter를 선언해주면 모든 field에 대한 getter가 생성되면서 자연스럽게 메서드 재정의도 이뤄진다.

getAuthorities(), getPassword(), getUsername()만 재정의 필수이고 나머지는 기본값을 사용한다면 재정의 하지 않아도 된다.
@Getter
public class CustomUserDetails implements UserDetails {
    // UserDetails 동작에 꼭 필요한 속성들
    private String username;               // DB의 PK : admin
    private String password;               
    private Collection<? extends GrantedAuthority> authorities;

    //UserDetails에 추가하고 싶은 내용
    private String userRealName;           // 사용자의 진짜 이름: 관리자

    public CustomUserDetails(User user) {
        this.username = user.getUserId();
        this.password = user.getPassword();
        this.authorities = user.getAuthorities();
        this.userRealName = user.getUsername();
    }
}

그리고 Repository에서 DB 조회 결과를 가지고 있는 User를 생성자의 파라미터로 받아서 필요한 값을 설정해주면 된다. (값만 잘 설정하면 되므로 Repository에서 CustomUserDetails를 반환해도 상관은 없다.)

 

UserDetailsService 커스터마이징

 

CustomUserDetailsService implements UserDetailsService

UserDetailsService 역시 수정이 필요하다. UserDetailsService는 사용자의 id(내부적으로는 username)에 해당하는 사용자 정보를 받아서 거기에 해당하는 UserDetails 객체를 반환한다. 이때 사용되는 메서드가 loaduserByUsername이다.

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

따라서 UserDetailsService를 확장할 때는 loaduserByUsername 메서드를 재정의해서 DB에서 조회한 값을 바탕으로 CustomUserDetails를 생성 후 반환해주면 된다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = userRepository.findByUserId(userId)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        return new CustomUserDetails(user);
    }
    
    @PostConstruct // 회원 가입 처리 전 테스트 용
    public void init() {
        User user = User.builder().userId("doding").password("1234").username("관리자").build();
        user.addAuthority("ADMIN");
        user.addAuthority("GOD");

        userRepository.save(user);
    }
}

아울러 회원 가입 처리를 완료하기 전에 테스트용으로 @PostConstruct 상태에서 admin을 추가해보자.

 

CustomUserDetailsService 사용 설정

마지막으로 할 일은 SecurityFilterChain을 구성하면서 HttpSecurity#userDetailsService에 설정하면 된다.

// @Bean
// public UserDetailsService userDetailsService(PasswordEncoder pe, DataSource ds) {
//     . . .
// return manager;
// }

@Bean
public SecurityFilterChain webSecurityFilterChain(HttpSecurity http, 
                                        CustomUserDetailsService userDetailsService)
    throws Exception {
    http.userDetailsService(userDetailsService);
    http.csrf(csrf -> csrf.disable());        
    http.authorizeHttpRequests(requests -> requests
                .requestMatchers("/secured/user").authenticated()
                .requestMatchers("/secured/staff").hasRole("STAFF")
                .requestMatchers("/secured/admin").hasRole("ADMIN")
                .anyRequest().permitAll());
    http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll());
    http.exceptionHandling(ex -> ex.accessDeniedPage("/secured/access-denied"));
    return http.build();
}

이제 UserDetails와 UserDetailsService가 CustomUserDetails와 CustomUserDetailsService로 완벽히 교체되었다.

application 실행 후 h2-console을 통해서 조회해보면 users와 authroity table에 우리가 생성한 정보가 잘 저장되어있음을 알 수 있다.

 

결과 확인

이제 화면에서 기존의 username 대신 realName을 사용하보자.

<header>
  <h1>Welcome Doding's Spring Security</h1>
  <!-- 로그인 상태일 때 표시 -->
  {{#SPRING_SECURITY_CONTEXT.authentication.principal.userRealName}}
  <div>
    <span>환영합니다. {{.}}님</span>
    <a href="/logout">로그아웃</a>
  </div>
  {{/SPRING_SECURITY_CONTEXT.authentication.principal.userRealName}}
  <hr />
</header>

이제 admin으로 로그인하면 아이디 대신 관리자라는 이름이 출력되는 것을 확인할 수 있다.

admin 보다는 관리자!

 

테스트 수정

기존의 UserDetailsService를 사용하던 부분을 CustomUserDetailsService로 변경하는 작업이 필요하다.

@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@Import({SecurityConfig.class, CustomUserDetailsService.class}) // CustomerDetailsService 빈 로딩
public class AbstractSecurityTest {

    @MockitoBean
    // mocking 해야 하므로 UserDetailsService가 아닌 CustomUserDetailsService로 변경
    CustomUserDetailsService userDetailsService; 
    . . .
}
@DataJpaTest
@Import({ SecurityConfig.class, CustomUserDetailsService.class })
@ActiveProfiles("test")
public class UserDetailsTest {
    . . .
}

'Spring security > 01.Security' 카테고리의 다른 글

11. RememberMe 서비스  (0) 2022.11.22
10. CSRF 처리  (0) 2022.11.22
08. JDBC를 이용한 인증  (0) 2022.11.22
07. 로그인/로그아웃 커스터마이징  (0) 2022.11.18
06. 사용자 및 경로 관리  (0) 2022.11.18
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.