본문 바로가기
Course/Spring Security

[netplix-security-a] UserDetailsService 와 UserDetailsManager 구현

by Lpromotion 2024. 9. 29.
Netplix 구독형 멤버십 프로젝트로 배우는 SpringSecurity
[Ch 3. UserDetails와 사용자 관리] - 01. UserDetails 살펴보기
강의를 바탕으로 실습 내용을 정리하였습니다.

목차
 

1. UserDetailsService 구현

InMemoryUserDetailsManager 를 활용해서 UserDetailsService 를 구현해보자

  • 이를 위해 먼저 User 를 구현해야 함

 

User 클래스

  • username, password, authority 는 모두 final 으로 설정하여 값을 변경할 수 없도록 지정
  • 간단한 예제를 위해서 authority 는 하나만 설정
package fast.campus.fcss01.user;

...

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);
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }
}

 

인메모리 기반 UserDetailsService 구현

UserDetailsService 를 구현하는 InMemoryUserDetailsService 를 생성

  • 인메모리에서 사용자를 조회하기 위해 먼저 사용자 List 객체를 생성 (List<UserDetails>)
  • loadUserByUsername 메소드에서 users 를 순회하며 username 으로 검색
  • 만약 존재하지 않는다면 UsernameNotFoundException 을 던지도록 구현
package fast.campus.fcss01.user;

...

public class InMemoryUserDetailsService implements UserDetailsService {

    private final List<UserDetails> users;

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

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return users.stream()
                .filter(user -> user.getUsername().equals(username))
                .findFirst()
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
}

 

UserDetailsService 와 PasswordEncoder 빈 등록

설정 클래스를 통해 UserDetailsService 와 PasswordEncoder 를 빈으로 등록

  • 3명의 임시 유저를 등록
  • InMemoryUserDetailsService 를 UserDetailsService 로 지정
  • PasswordEncoder 로는 NoOpPasswordEncoder 활용
package fast.campus.fcss01.config;

...

@Configuration // 클래스를 구성 클래스로 구분
public class SecurityConfig {

    @Bean // 반환되는 값을 스프링 컨텍스트에 반영
    public UserDetailsService userDetailsService() {
        UserDetails danny = User.withUsername("danny.kim")
                .password("12345")
                .build();
        UserDetails steve = User.withUsername("steve.kim")
                .password("23456")
                .build();
        UserDetails harris = User.withUsername("harris.kim")
                .password("34567")
                .build();
        List<UserDetails> users = List.of(danny, steve, harris);
        return new InMemoryUserDetailsService(users);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // PasswordEncoder 로 NoOpPasswordEncoder를 활용
        return NoOpPasswordEncoder.getInstance();
    }

}

 

API 테스트 해보기

간단한 컨트롤러 생성

package fast.campus.fcss01.controller;
...

@RestController
public class HelloController {

    @GetMapping("/api/v1/hello")
    public String hello() {
        return "Hello, Spring Security";
    }
}

테스트를 위해 build.gradle에서 “jpa” 의존성을 주석처리함

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
//    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    implementation    'org.springframework.boot:spring-boot-starter-security'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

데이터베이스를 사용하지 않고 InMemoryUserDetailsService로 사용자 인증을 처리하기 위해 주석처힘

 

username 또는 비밀번호가 틀렸을 때

  • 401 Unauthorized

 

정상적인 케이스일 때

  • danny.kim // 12345 입력
  • 200 OK 응답
  • “Hello, Spring Security” 반환

 

2. UserDetailsManager 구현

일반적인 애플리케이션에는 사용자를 관리하는 기능이 필요함

  • 회원가입을 통해 새로운 사용자 추가
  • 내 정보에서 사용자 정보 수정
  • 회원 탈퇴를 위한 사용자 삭제

이런 기능을 위해서는 UserDetailsManager 가 필요함

  • 인터페이스로써 구현체가 필요함

 

사용자 세부 정보 서비스에서는 MySQL 데이터베이스와 연결을 하여 사용자를 DB 에서 조회함

 

암호 인코더는 기본을 사용

 

JdbcUserDetailsManager 이용

데이터베이스에서 사용자를 관리하는 경우에는 JdbcUserDetailsManager 를 활용할 수 있음

  • 데이터베이스는 MySQL 을 사용
  • 사용자를 위한 users 테이블과 권한 관리를 위한 authorities 테이블을 생성
  • users 테이블에는 사용자 ID, 암호, 활성화 여부를 저장하는 3개의 필드가 존재함
  • authorities 테이블에는 사용자 ID 와 그 사용자에게 부여된 권한을 나타내는 authority 필드가 존재함

 

스프링부트에서는 schema.sql 과 data.sql 파일을 통해 초기 테이블을 설정할 수 있음

 

프로젝트 의존성

Lombok, Spring Web, Spring Security, Spring Data JPA, MySQL Driver 의존성을 추가함

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation    'org.springframework.boot:spring-boot-starter-security'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

도커를 활용하여 로컬에 MySQL 띄우기

도커 설치 확인
$ docker -v

도커 실행 툴 OrbStack 다운로드
$ brew install orbstack

MySQL 도커 이미지 다운로드
$ docker pull mysql:latest

다운로드한 도커 이미지 확인
$ docker images

MySQL 도커 컨테이너 생성 및 실행
$ docker run --name spring-security-mysql -e MYSQL_ROOT_PASSWORD=1234 -d -p 3306:3306 mysql:latest

 

나는 윈도우 환경이라 WSL2에서 mysql 컨테이너를 실행했다.

 

Datagrip 으로 데이터베이스 조회

추가 -> Data Source -> MySQL 선택

 

host: localhost
port: 3306
username: root
password: admin

정보 입력 후 완료

 

SQL Console에서 데이터베이스 생성

Database name 으로 spring 설정

 

application.properties 설정

spring.datasource, spring.jpa, spring.sql 관련 설정 추가

  • driver-class-name=com.mysql.cj.jdbc.Driver => MySQL 드라이버 설정
  • url=jdbc:mysql://localhost:3306/spring => 로컬의 3306 포트의 spring 데이터베이스로 연결
  • username=root => 기본값인 root 로 설정
  • password=admin => 도커 설정 시 입력한 패스워드인 1234 로 설정

 

  • hibernate.dialect => MySQLDialect 로 설정
  • ddl-auto=create-drop => 애플리케이션 실행 후 종료 시 테이블 삭제

 

  • init.mode=always => 애플리케이션 실행 시 sql 파일 실행
  • data, schema-locations 으로 sql 파일 위치 선정
spring.application.name=fcss-01

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/spring
spring.datasource.username=root
spring.datasource.password=1234

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=create-drop

spring.sql.init.mode=always
spring.sql.init.data-locations=classpath:data/data.sql
spring.sql.init.schema-locations=classpath:data/schema.sql

 

ddl-auto=create-drop 가 적용되지 않는 문제 발생

설정대로 라면 애플리케이션을 실행하고 중지하면 테이블이 삭제되어야 하는데,
다음 실행 시 데이터가 그대로 남아있고 실행할 때마다 데이터가 쌓임.

 

schema.sql

resources 아래에 data 디렉토리 생성

  • data 디렉토리 하위에 schema.sql 파일 생성
  • users 테이블과 authorities 테이블 생성
CREATE TABLE IF NOT EXISTS `spring`.`users` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(100) NOT NULL,
    `password` VARCHAR(100) NOT NULL,
    `enabled` INT NOT NULL,
    PRIMARY KEY (`id`)
    );

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

 

data.sql

resources/data 디렉토리 하위에 data.sql 파일 생성

  • authorities 와 users 테이블에 각각 1개의 row 를 입력
INSERT INTO `spring`.`authorities` VALUES (NULL, 'danny.kim', 'WRITE');

INSERT INTO `spring`.`users` VALUES (NULL, 'danny.kim', '12345', TRUE);

 

데이터 조회

Datagrip 에서 데이터 조회 시도

  • data.sql 을 통해 입력한 데이터 1건 조회 성공

 

설정 클래스 생성

config 패키지 생성 후 하위에 SecurityConfig 클래스 생성

  • DataSource 를 파라미터로 주입받음
  • JdbcUserDetailsManager 를 반환하도록 설정
package fast.campus.fcss01.config;

...

@Configuration // 클래스를 구성 클래스로 구분
public class SecurityConfig {

    @Bean // 반환되는 값을 스프링 컨텍스트에 반영
    public UserDetailsService userDetailsService(DataSource dataSource) {
        return new **JdbcUserDetailsManager**(dataSource);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // PasswordEncoder 로 NoOpPasswordEncoder를 활용
        return NoOpPasswordEncoder.getInstance();
    }

}

 

포스트맨을 활용하여 테스트

데이터베이스에 존재하는 유저 정보를 정확하게 입력하면 조회 성공

  • danny.kim // 12345

 

만약 정보를 잘못 입력하면 401 Unauthorized 응답을 받음

 

 

GitHub

https://github.com/lpromotion/fcss-01/commit/10d92f596287d15bd3a0b6f13fe681223c89dcf6

https://github.com/lpromotion/fcss-01/commit/880508e2e716ff6790b6c4613d34ec3c36467b5d

반응형

댓글