본문 바로가기
프로그래밍/기타

[Spring Security + JPA] JPA를 사용해서 Spring Security User 인증 서비스 만들기

by 사바라다 2022. 12. 28.

안녕하세요. 오늘은 유저 정보를 MySQL에 저장하고 JPA를 활용하여 이 정보를 기준으로 Spring Security에서 유저의 인증을 확인하는 샘플을 만들어보도록 하겠습니다. 오늘 구현할 부분은 아래의 이미지에서 색깔로 칠해져 있는 부분입니다. 이부분들을 커스터 마이징하면 DB에서 유저를 가져와서 이를 인증에 이용할 수 있게 됩니다.

오늘 구현한 샘플은 github에 올려두었습니다. 전체 코드가 궁금하신 분들은 github repository에 접근하시면 확인이 가능합니다.

구현 필요 부분

오늘 실습에서 Spring Security의 Interface를 이해하고 있어야하는 부분은 아래와 같습니다. 해당 Interface가 하는 역할 및 구현 부분에 대한 정의는 이전 포스팅[Spring Security] Spring Security에서 UserDetailsService와 PasswordEncoder Interface 분석하기에서 자세히 작성했으니 참고해주시면 좋을것 같습니다.

  • UserDetailsService : User 정보를 읽어오는 interface
  • UserDetailsManager : User 정보 쓰고 수정하는 interface
  • UserDetails : User 정보의 구현 model interface
  • PaaswordEncoder : 패스워드를 인코딩하고 매칭을 확인하는 interface

개발 환경 및 의존성

오늘 포스팅에서의 개발환경은 아래와 같습니다. H2 DB를 사용하여 Spring Security의 구현에 집중하였습니다.

  • database : H2
  • kotlin : 1.6.21
  • Spring Boot 2.7.5
  • JPA
  • Spring Security

환경 설정은 build.gradle.kts 파일을 열어보면 알 수 있습니다. 해당 샘플의 구성은 아래와 같습니다. 이를 통해서 Spring 프레임워크 기반의 web, securtiy, jpa를 사용하고 언어는 kotlin, 그리고 database로 H2를 사용한다는 것을 알 수 있습니다.

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

runtimeOnly("com.h2database:h2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.mockk:mockk:1.12.0")

yaml 파일

datahbase 및 JPA의 기본적인 설정은 application.yaml 파일로 진행하였습니다. 이렇게하여 이번 샘플에서 보고자 하는 Security 설정만 코드로 모아 집중하여 볼 수 있습니다. 설정은 h2 설정과 JPA 설정입니다. 주요한 내용은 아래 코드에 주석으로 달아두었습니다.

spring:
  h2:
    console:
      enabled: true # console을 사용합니다. 접근 path는 /h2-console
  profiles:
    active: local # service 실행 될 때 함께 실행되고 종료될 때 삭제 되는 설정
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:~/test
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: update # JPA에 의해서 serviceh 실행시 필요한 DDL을 h2에 자동으로 실행
    show-sql: true
    properties:
      hibernate:
        format_sql: true

User Entity 및 Repository 생성하기

아래는 User Entity 입니다. 이는 h2에는 아래의 코드에 기반해서 테이블 DDL이 생성됩니다. email과 password, 그리고 Role(역할)을 저장하고 있습니다. 일반적으로 password는 암호화 하지만 여기서는 테스트 편의성을 위해서 암호화는 진행하지 않았습니다. 또한 UserDetailsService의 interface의 스펙으로는 username에 대응되는 값으로 유저를 찾을 수 있어야합니다. 해당 샘플에서는 email이 그것입니다.

@Entity
@Table(name = "sabarada_user")
class User(
    @Id
    val email: String,
    var password: String
) {

    @Enumerated(EnumType.STRING)
    var role: Role = Role.USER
}
@Repository
interface UserRepository : JpaRepository<User, Long> {

    fun findByEmail(email: String): User?
}

Spring Security 코드 작성하기

UserDetails 생성하기

그럼 이제 본격적으로 Spring Security를 커스터마이징해보도록 하겠습니다. 가장먼저 구현해야 할 것은 Spring Security의 인증에 사용할 유저에 해당하는 UserDetails 모델을 만드는것입니다. 이 모델은 UserDetailsService에서 사용될 모델입니다. UserDetails의 자세한 interface는 이전 포스팅에서 다루었기 때문에 생략하고 바로 구현으로 들어가도록 하겠습니다.

코드는 아래와 같습니다. UserDetailsImpl 클래스는 UserDetails interface를 구현(implementation)합니다. 그리고 User 클래스를 parameter로 받아서 내부 값들을 초기화해줍니다.

User에 UserDetails interface를 구현할 수 있습니다. 하지만 그렇게하면 Spring Security 용도의 모델과 범용적인 User Entity 모의 결합도가 높아지기 때문에 관심사 분리 차원에서 별도의 모델을 가지는것이 좋습니다.

class UserDetailsImpl(
    private val user: User
) : UserDetails {

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return mutableListOf(SimpleGrantedAuthority(user.role.name))
    }

    override fun getPassword(): String {
        return user.password
    }

    override fun getUsername(): String {
        return user.email
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    override fun isEnabled(): Boolean {
        return true
    }
}

UserDetailsService 생성하기

위에서 UserDetailsService interface에서 사용할 UserDetails Model을 완성하였습니다. 이제는 이를 활용하여 유저 데이터를 가져오는 UserDetailsService interface를 구현해보도록 하겠습니다. interface에 명시되어 있는 loadUserByUsername의 역할은 User 데이터를 가져오는 것입니다. 따라서 구현도 그 스펙에 맞춰서 구현하시면 됩니다.

@Component
class UserDetailsServiceImpl(
    private val userRepository: UserRepository
) : UserDetailsService {

    override fun loadUserByUsername(username: String?): UserDetails {

        if (username == null) {
            return throw NullPointerException("can't find this user. username is null")
        }

        val user: User = userRepository.findByEmail(username)
            ?: throw NullPointerException("can't find this user. username: $username")

        return UserDetailsImpl(user)
    }
}

Config

커스터 마이징한 UserDetailsService를 사용하기 위한 설정을 해보도록 하겠습니다. 아래 코드를 보시면 userDetailsService의 bean을 새롭게 사용하는 것을 알 수 있습니다.

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun ignoringCustomizer(): WebSecurityCustomizer? { // /h2-console/** 에 대해서 인증없이 접근할 수 있도록 처리
        return WebSecurityCustomizer { web: WebSecurity ->
            web
                .ignoring()
                .antMatchers("/h2-console/**")
        }
    }

    @Bean
    fun userDetailsService(
        userRepository: UserRepository
    ): UserDetailsService {
        return UserDetailsServiceImpl(userRepository)
    }
}

추가로 아래 설정을 봐주시면됩니다. 아래 설정은 AuthenticationProvider는 기본 사용하는것으로 두고 userDetailsService와 PasswordEncoder만 변경해줄 수 있는 방법입니다. 아래처럼 UserDetailsService는 우리가 구현한 것을 주입해주고, passowrdEncoder는 현재 암호화는 사용하지 않기 때문에 NoOpPasswordEncoder를 사용할 수 있도록 처리하여주시면 됩니다.

@Configuration
class SpringWebSecurityConfigurerAdapter(
    private val userDetailsServiceImpl: UserDetailsServiceImpl
) : WebSecurityConfigurerAdapter() {

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.userDetailsService(userDetailsServiceImpl)
            .passwordEncoder(NoOpPasswordEncoder.getInstance())
    }
}

Test 진행하기

이렇게 하면 Spring Security까지 세팅이 완료되었습니다. 실행을 후 로컬에서 테스트를 해보도록 하겠습니다.

아래 API를 만들어 두었고 local에서 접근하여 200을 반환받으면 성공입니다.

@RestController
@RequestMapping("/example")
class ExampleController {

    @GetMapping
    fun example(): String {
        return "done"
    }

}

실패 테스트

먼저 basic Auth 인증 없이 접근한다면 아래처럼 401 코드로 접근에 실패하게 되는것을 확인하실 수 있습니다.

➜ curl -u koangho93@naver.com:1235 -i -X GET localhost:8080/example
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Set-Cookie: JSESSIONID=753FBB8E43C2C81B3016CE0D81E9AF1C; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm"
Content-Length: 0
Date: Sun, 18 Dec 2022 07:15:18 GMT

성공 테스트

성공 테스트를 진행하기 위해서는 Basic Auth에 database의 User로 사용하는 테이블에 데이터를 넣어주고 해당 데이터를 기반으로 로그인하면 성공하는 것을 확인할 수 있습니다. 아래 이미지는 제가 만든 User table 이며 현재 koangho93@naver.com이라는 email이 저장되어 있는 것을 확인할 수 있습니다. 또한 비밀번호도 알 수 있습니다.

위 정보를 바탕으로 접근한다면 우리는 원하는 HTTP 200 OK 의 결과를 아래처럼 얻을 수 있습니다.

➜ curl -u koangho93@naver.com:1234 -i -X GET localhost:8080/example
HTTP/1.1 200
Set-Cookie: JSESSIONID=10433FAD12BBE42FF273C3638F083B32; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 4
Date: Sun, 18 Dec 2022 07:15:22 GMT

마무리

오늘은 이렇게 Spring Security에서 database의 테이블에 있는 User 데이터를 기반으로 로그인하는 방법의 기초에 대해서 알아보는 시간을 가져보았습니다.

오늘 살펴본 샘플 코드는 github repository로 올려두었습니다.

관심있으신 분들은 살펴보시면 좋을것 같습니다.

감사합니다.

참조

[1] Spring Security In Action

댓글