[Spring] 사용자 관리

노유성·2023년 7월 31일
0
post-thumbnail

들어가며

이번 포스팅은 UserDetailsService를 제대로 이해하기 위한 것으로 UserDetials, GrantedAuthority UserDetailsManager interface등에 대해서 자세히 알아볼 예정이다.

Spring Security의 인증 구현

사용자 관리를 위해서는 UserDetailService 및 UserDetailsManager 인터페이스를 이용한다. UserDetailService는 사용자 이름으로 사용자를 검색하는 역할만 한다. UserDetailsManager는 대부분의 어플리케이션에 필요한 사용자 추가, 수정, 삭제 작업을 수행한다.

이렇게 두 개의 인터페이스로 분리함으로써 앱에 필요 없는 동작을 구현하도록 강제하지 않아 유연성이 향상된다.

사용자가 수행할 수 있는 작업을 권한이라고 하며 GrantedAuthority 인터페이스로 나타낸다. 사용자는 하나 이상 혹은 0개의 권한을 갖는다.

UserDetails

UserDetails 인터페이스는

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	String getPassword();

	String getUsername();

	boolean isAccountNonExpired();

	boolean isAccountNonLocked();

	boolean isCredentialsNonExpired();

	boolean isEnabled();
}

위와 같이 구성되어있다. 2개의 getter는 사용자의 이름과 암호를 반환한다. 나머지 5개는 사용자가 app의 리소스에 접근할 수 있도록 권한을 부여하기 위한 것이다. getAuthorities()는 사용자가 가진 권한을 반환하도록 구현하며, 나머지 boolean값을 반환하는 메소드는 위에서부터 순서대로 계정 만료 여부, 계정 잠금 여부, 자격 증명 만료 여부, 계정 비활성화 여부를 반환한다.

UserDetails 구현체 에씨

public class SimpleUser implements UserDetails {
    private final String username;
    private final String password;

    public SimpleUser(String username, String password) {
        this.username = username;
        this.password = password;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    ...

간단하게 UserDetails를 구현한 예제이다. boolean을 반환하는 함수들도 비즈니스에 따라 로직을 구현할 수 있다.

GrandtedAuthority

public interface GrantedAuthority extends Serializable {
	String getAuthority();
}

GrandtedAuthority는 권한을 String으로 반환하는 하나의 메소드를 정의하고 있다.

GrandtedAuthority g1 = () -> "read";
GrandtedAuthority g2 = new SimpleGrandtedAuthority("read");

위와 같이 람다식으로 구현할 수도 있다.

책임 결합

@Entity
public class SimpleUser implements UserDetails {
    @Id
    private int id;
    private final String username;
    private final String password;

    public SimpleUser(String username, String password) {
        this.username = username;
        this.password = password;
    }

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

위 코드를 보면 어떤가? 위 코드는 JPA annotation을 포함하고 UserDetails를 재정의하고 있다. 하나의 class가 두 개의 책임을 갖는 것이다. 이렇게 정의하는 것은 좋지 않다.

해결

@Entity
public class User {
    @Id
    private int id;
    private final String username;
    private final String password;

    ... getter setter

위 코드는 JPA entity의 책임만 있다.

public class SecurityUser implements UserDetails {
    private final String username;
    private final String password;
    private final String authority;

    public User(String username, String password, String authority) {
        this.username = username;
        this.password = password;
        this.authority = authority;
    }
	...

위 코드는 UserDetails의 계약을 매핑하는 일만한다. 이렇게 하나의 class가 여러 책임을 갖지 않게 하도록 유의해야 한다.

UserDetailsService

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsService는 username(단, 고유해야 한다)으로 사용자의 세부 정보를 얻을 수 있는 메소드 하나만을 명세하고 있다. 사용자가 존재하지 않으면 UsernameNotFoundException을 던진다.

구현

public class User implements UserDetails {
    private final String username;
    private final String password;
    private final String authority;

    public User(String username, String password, String authority) {
        this.username = username;
        this.password = password;
        this.authority = authority;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(
                () -> authority
        );
	...

먼저 UserDetilasService를 구현하기 위해 UserDetails의 구현체를 만든다.

public class InMemoryUserDetailService implements UserDetailsService {

    private final List<UserDetails> users;

    public InMemoryUserDetailService(List<UserDetails> users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return users.stream()
                .filter( u -> u.getUsername() == username)
                .findFirst()
                .orElseThrow(() ->new UsernameNotFoundException("there is no user"));
    }
}

다음은 UserDetailsService의 구현체이다. 생성자로 전체 유저에 대한 리스트를 받아서 저장하고 loadUserByUsername()가 호출되면 user를 찾아서 반환하거나 예외를 던진다.

@Bean
public UserDetailsService userDetailsService() {
    UserDetails user = new User("john","12345","read");
    List<UserDetails> list = List.of(user);

    return new InMemoryUserDetailService(list);
}

@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

위와 같이 User를 새로 만들어서 UserDetails에 담고 이를 UserDetailsService를 재정의한 InMemoryUserDetailService의 생성자로 주어서 빈으로 등록할 수 있다.

UserDetailsManager

public interface UserDetailsManager extends UserDetailsService {
	void createUser(UserDetails user);

	void updateUser(UserDetails user);

	void deleteUser(String username);

	void changePassword(String oldPassword, String newPassword);

	boolean userExists(String username);
}

UserDetailsManager는 user를 CURD하는 기능과 user의 존재여부를 확인하는 기능, 총 5가지의 method를 게약하고 있다.

구현

이에 대한 구현은 JdbcUserDetailsManager를 이용할 수 있다.

implementation 'mysql:mysql-connector-java'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

mysql과 jdbc를 사용하기 위한 두 의존성을 추가해준다.

그리고 resource/ 아래에 두 파일을 추가해준다.

schema.sql

CREATE TABLE IF NOT EXISTS `spring`.`users` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NOT NULL,
    `password` VARCHAR(45) NOT NULL,
    `enabled` INT NOT NULL,
    PRIMARY KEY (`id`);
)

CREATE TABLE IF NOT EXISTS `spring`.`authorities` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NOT NULL,
    `authority` VARCHAR(45) NOT NULL,
    PRIMARY KEY (`id`);
)

data.sql

INSERT IGNORE INTO `spring`.`authorities` VALUES(NULL, 'john', 'write')
INSERT IGNORE INTO `spring`.`users` VALUES(NULL, 'john', '12345', "1")

이렇게 저장해주면은 알아서 스프링 부트가 실행해서 테이블을 생성하고 데이터를 추가해준다.

그리고 application.properties파일에

spring.datasource.url=jdbc:mysql://localhost/spring
spring.datasource.username=유저
spring.datasource.password=비밀번호

까지 입력을 해주면 JdbcUserDetailsManager가 DB에 연결되기 위한 DataSource가 자동으로 주입된다.

@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
}

@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

그리고 이렇게 datasource를 연결해주면 db에 등록된 유저의 id, pw로 서버의 앤드포인트에 접근이 가능하다.

profile
풀스택개발자가되고싶습니다:)

0개의 댓글