웹/Spring

Spring Security에서 DB에 존재하는 사용자 인증하기

Themion 2022. 4. 12. 13:37

Spring Security가 자체적으로 세션 관리 기능을 지원하기는 하지만, 이는 어디까지나 자체적인 기능이지 Spring 서버와 연결된 DB의 정보와는 무관한 기능이다. 따라서 Spring Security를 제대로 이용하기 위해선 우선 Spring Security와 DB의 회원 정보를 연결하는 과정이 필요하다.

우선은 Spring Security와 연결할 DB, 그리고 DB와 연결된 Repository가 필요하다. 이 글에서는 H2 DB에서 아래와 같이 정의된 테이블과, 해당 테이블을 JDBC Template을 이용해 관리하는 Repository가 있다고 가정하겠다.

create table user
(
    id          bigint generated by default as identity,
    username    varchar(255) not null,
    password    varchar(255) not null,
    role        varchar(255) not null,
    authority   varchar(255) not null,
    primary key(id)
);

 

Spring Security에서는 사용자 정보를 UserDetails라는 Wrapper 클래스로 묶어서 관리한다. 이 UserDetails는 Spring Security에서 interface로 구현되어 있으므로, UserDetails를 상속받아 일부 기능을 구현한 클래스를 새로 만들어야 한다.

UserDetails에서 구현해야 하는 기능은 크게 세 가지로 나뉘는데, 사용자의 username / password의 getter, 사용자의 권한 getter, 그리고 사용자 계정의 사용 가능 여부이다. 사용자의 username / password getter는 미리 구현한 User 도메인의 getter를 사용하면 되고, 사용자 계정의 사용 가능 여부는 단순히 true를 반환하기만 해도 간단한 구현에는 문제가 없다. 중요한 건 사용자의 권한 getter인데, 이 getter는 이후 사용자의 인가 과정에서 매우 중요하게 사용된다. 일반적으로는 사용자의 권한을 SimpleGrantedAuthority 클래스에 담아 List에 담아 반환하면 되는데, Spring Security에서는 사용자의 권한을 일반적인 권한 Authority와 특수한 권한 Role 두 가지로 분류하므로 이 둘을 잘 구분해서 추가해야 한다.

package themion7.spring_security_jwt.security;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AllArgsConstructor;

// 직접 구현한 User 클래스
import themion7.spring_security_jwt.domain.User;

@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        
        // 일반적인 권한은 그냥 SimpleGrantedAuthority로 만들어 추가
        authorities.add(new SimpleGrantedAuthority(user.getAuthority()));
        // 특수한 권한인 Role은 추가할 때 권한 앞에 문자열 "ROLE_"을 더해 Role임을 명시해야 함
        authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));

        return authorities;
    }

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

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

    // 사용자 계정의 사용 가능 여부는 단순히 true를 반환하기만 해도 된다
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

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

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

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

 

UserDetails의 구현을 마쳤다면, 다음은 UserDetails를 활용하는 interface인 UserDetailsService 역시 구현해야 한다. UserDetailsService에서 구현해야 하는 기능은 String 타입인 username을 가진 사용자를 찾아 직접 구현한 UserDetails 클래스 형태로 반환하는 기능이다.

package themion7.spring_security_jwt.security;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import lombok.AllArgsConstructor;

// 직접 구현한 User 클래스
import themion7.spring_security_jwt.domain.User;
// DB에서 사용자 정보를 가져오는 Repository 클래스
import themion7.spring_security_jwt.repository.UserRepository;

@Component
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository repo;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = this.repo.findByUsername(username).orElseThrow(() -> {
            throw new UsernameNotFoundException("Username " + username + " not found");
        });
        return new UserDetailsImpl(user);
    }
    
}

 

UserDetailsUserDetailsService를 구현했다면 다음으로는 비밀번호를 암호화하는 클래스인 PasswordEncoder를 Component로 등록해야 한다. PasswordEncoder는 위의 두 interface와는 달리 모든 기능을 구현한 클래스가 이미 Spring Security 안에 존재하므로, 별다른 기능을 구현할 필요 없이 기존에 구현된 PasswordEncoder를 상속받은 클래스를 새로 만들기만 한 다음 Component로 등록하면 된다. 이 글에서는 BCryptPasswordEncoder를 사용하겠다.

package themion7.spring_security_jwt.security;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class PasswordEncoder extends BCryptPasswordEncoder {
}
package themion7.spring_security_jwt.repository;

// ... 기존 import들 ...
import themion7.spring_security_jwt.security.PasswordEncoder;

@Transactional
public class UserRepositoryImpl implements UserRepository {

    // ... 기존 코드들 ...
    
    // 비밀번호를 암호화하기 위해 Repository의 멤버 변수로 선언
    private final PasswordEncoder encoder = new PasswordEncoder();

    // ... 기존 코드들 ...
    
    @Override
    public User save(User user) {
        // 사용자를 저장하기 전 사용자의 비밀번호를 PasswordEncoder를 이용해 암호화
        user.setPassword(encoder.encode(user.getPassword()));
		
        // ... 기존 코드들 ...
    }

    // ... 기존 코드들 ...

}

 

PasswordEncoder까지 구현했다면, 이제 작성한 코드를 Spring Security에 적용하기 위한 설정 클래스를 작성해야 한다. 일반적인 Spring의 설정 클래스와는 다르게, Spring Security의 설정 클래스는 WebSecurityConfigurerAdapter를 상속받아야 하고, Annotation으로 @Configuration 외에 @EnableWebSecurity 역시 사용해야 한다.

이 글에서 설정 클래스에 작성할 코드는, 위에서 Component로 만든 UserDetailsService와 PasswordEncoder를 가진 AuthenticationProvider를 Bean으로 등록한 뒤 해당 AuthenticationProvider를 인증 절차에 삽입하는 코드이다.

package themion7.spring_security_jwt.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import lombok.AllArgsConstructor;
import themion7.spring_security_jwt.security.PasswordEncoder;
import themion7.spring_security_jwt.security.UserDetailsServiceImpl;

@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // AuthenticationProvider에 등록할 UserDetailsService
    private UserDetailsServiceImpl userDetailsService;
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth
            // 인증 절차에 Bean으로 등록한 AuthenticationProvider를 삽입
            .authenticationProvider(authenticationProvider());
    }

    @Bean
    DaoAuthenticationProvider authenticationProvider() {
        // 인증 절차에 삽입할 AuthenticationProvider
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        
        // 직접 작성한 UserDetailsService와 PasswordEncoder를 등록
        provider.setUserDetailsService(this.userDetailsService);
        provider.setPasswordEncoder(new PasswordEncoder());
        
        // AuthenticationProvider를 반환
        return provider;
    }
}

 

위 과정을 모두 거치게 되면 Spring Security에서 기존 DB에 등록한 User를 이용해 인증 과정을 진행할 수 있다.