본문으로 바로가기
반응형

본 예제는 Spring 5.x &Spring boot 2.3.0 버전을 사용합니다.

또한 Kotlin (v1.4.20)로 예제를 작성하며 IntelliJ CE를 사용합니다.


앞서서 웹 애플리케이션에 대한 개발 부분을 얘기했습니다.

보통 대부분의 사이트를 보면 main 페이지는 접근이 가능하나 특정 메뉴에 접속을 위해서는 로그인이 필요합니다.

로그인 같은 인증 작업은 Spring Security를 통해서 가능합니다.

환경설정

먼저 spring security를 pom.xml 파일의 dependency에 추가합니다.

  <!-- 로그인을 위한 security 추가 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

이렇게만 추가하고 localhost:8080에 접속하면 기본적으로 제공하는 로그인 창이 브라우저에 나타납니다.

이때 사용자이름은 'user', console창에 나타나는 security password로 로그인이 가능하나, 문제는 사용자/비번이 지정되어 있고, localhost의 어떤 페이지에 접속하든지  인증 메시지가 뜹니다.

Spring Security의 구성

보통 로그인에 대한 창을 커스텀하게 만들고 메인 페이지에서는 로그인을 물어보지 않습니다.

따라서 인증이 필요한 페이지들에 대한 설정이 필요하며 로그인시 입력된 정보가 가입된 유저 인지도 확인하는 작업이 필요합니다.

이런 작업들을 명세하기 위하여 WebSecurityConfigurerAdapter() 상속한 클래스를 구현합니다.

@Configuration
@EnableWebSecurity
open class SecurityConfig : WebSecurityConfigurerAdapter() {
    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
            .antMatchers("/order/pay","/order/address","/select")
            .access("hasRole('USER')")
            .antMatchers("/h2", "/h2/**", "/", "/**")
            .access("permitAll")                        
            .and()
            .httpBasic()       
    }
    
    @Throws(Exception::class)
    override fun configure(auth: AuthenticationManagerBuilder) {
        ...
    }  
}

overrider 해야 하는 두개의 함수는 각각 다음의 내용을 포함합니다.

  • configure(http: HttpSecurity): 인증이 필요한 페이지들을 구분합니다. 직관적으로 h2를 접속하기 위한 경로와 root 경로를 제외하고는 인증을 필요로 하도록 설정되었음을 알 수 있습니다.
  • configure(auth: AuthenticationManagerBuilder) : 인증 가능한 사용자에 대한 정보를 표시합니다. DB로 부터 읽거나 아이디/패스워드를 하드 코딩할 수 있습니다.

MemoryAuthentication 이용

간단하게 지정된 아이디를 만들어 로그인이 필요한 경우 소스에 하드코딩하여 접근 가능한 아이디와 비번을 설정할 수 있습니다.

@Throws(Exception::class)
override fun configure(auth: AuthenticationManagerBuilder) {
    auth.inMemoryAuthentication()
        .withUser("user1")
        .password("{noop}password1")
        .authorities("USER")       //.roles("ADMIN")과 동일함
        .and()
        .withUser("user2")
        .password("{noop}password2")
        .authorities("USER");      //.roles("ADMIN");과 동일함
}

메모리에 'user1'과 'user2'를 추가합니다.

spring5부터는 password를 반드시 암호화 하도록 되어 있습니다.

하지만 예제에서는 암호화 하지 않을 예정이므로 {noop}을 붙여 줍니다.

authorities는 'USER'로 명명하며, 위 예제처럼. access("hasRole('USER')") 함수로 해당 페이지 열람 여부를 제한할 수 있습니다.

authroities 정하기 나름 입니다. 'QA', 'ADMIN' 등등..

JPA를 이용한 인증 - 사용자 등록부분 구성

대개 사용자 정보는 DB에 저장하며, 저장된 정보로 로긴 여부를 결정하는 코드를 만들어 봅니다.

먼저 로그인을 하려면 사용자 등록부분부터 만들어야 합니다.

위와 같이 만들기 위해 xml을 아래와 같이 작성합니다.

//registration.html
<body>
  <h1>Registeration</h1>

  <form method="POST" th:action="@{/register}" id="registrationModel">

      <label for="name">Username: </label>
      <input type="text" name="name"/><br/>

      <label for="pass">Password: </label>
      <input type="password" name="pass"/><br/>

      <label for="address">Street: </label>
      <input type="text" name="address"/><br/>

      <label for="phoneNumber">Phone: </label>
      <input type="text" name="phoneNumber"/><br/>

      <input type="submit" value="Register"/>
  </form>

</body>

Controller를 만들기 전에 이 html에서 입력된 정보를 담을 Model을 생성합니다.

data class RegistrationModel(
    val name: String?,
    val pass: String?,
    val address: String?,
    val phoneNumber: String?) {

    fun convertToUser(passwordEncoder: PasswordEncoder) = User(
        name = name,
        pass = passwordEncoder.encode(pass),
        address = address,
        phoneNumber = phoneNumber
    )
}

convertToUser() 함수는 RegistrationModel을 DB entity인 User로 바꾸는 작업을 합니다.

html에 User 객체를 직접 넘겨주지 않는 이유는 user객체에는 encoding 된 password를 담기 위함입니다.

이번엔 User data를 저장할 @Entity class를 만듭니다.

@Entity
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id: Long = -1,
    val name: String?,
    val pass: String?,
    val address: String?,
    val phoneNumber: String?
) : UserDetails {

    // Default constructor가 필요하다
    private constructor() : this(-1, null, null, null, null)

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
        arrayListOf(SimpleGrantedAuthority("USER"))

    override fun getPassword() = pass

    override fun getUsername() = name

    override fun isAccountNonExpired() = true

    override fun isAccountNonLocked() = true

    override fun isCredentialsNonExpired() = true

    override fun isEnabled() = true
}

이전 JPA 사용법에 설명했듯이 key가 되는 id가 반드시 필요합니다.

또한 JPA의 entity는 param이 없는 생성자가 필요하므로 private constructor()를 생성해 줍니다.

그리고 나머지  override 함수들은 UserDetails를 상속받으므로써 필요한 함수들입니다.

함수명만 봐도 알 수 있는 내용들인데 UserDetails configure(auth: AuthenticationManagerBuilder) 내부에서 JPA를 연결할 때 필요하므로 미리 구현해 놓습니다.

Repository는 간략하게 아래와 같이 정의합니다.

interface IUserRepository: CrudRepository<User, Long> {
    fun  findByName(name: String): User?
}

기본으로 생성되는 함수 이외에 로그인 시 name으로 user를 찾아서 해당 정보를 받아와야 하므로 해당 역할을 하는 findByName()을 추가합니다.

등록 절차를 처리하는 Controller를 만듭니다.

@Controller
@RequestMapping("/register")
class RegistrationController(val userRepo: IUserRepository, val passwordEncoder: PasswordEncoder) {

    @GetMapping
    fun registerForm() = "registration"

    @PostMapping
    fun processRegistration(model: RegistrationModel): String {
        userRepo.save(model.convertToUser(passwordEncoder))
        return "redirect:/login"
    }
}

localhost:8080/register로 접근 시 registration.html을 반환합니다.

또한 등록 정보가 입력되면 user repository를 이용하여 db에 쓴 이후에 localhost:8080/login 경로로 이동합니다.

이제 사용자 입력을 받아 저장하는 부분은 처리가 완료되었습니다.

 

추가적으로 spring security에 JPA를 연결하려면 위에서 구현한 UserDetails와 더불어 UserDetailsService를 구현해야 합니다.

UserDetailsService 클래스는 이름을 입력받아 userDetails를 반환하는 코드를 override 하도록 되어 있습니다.

@Service
class UserRepositoryUserDetailsService(@Autowired val userRepository: IUserRepository) : UserDetailsService {

    @Throws(UsernameNotFoundException::class)
    override fun loadUserByUsername(username: String): UserDetails {
        val user = userRepository.findByName(username)
        return user ?: throw UsernameNotFoundException("loadUserByUsername() - cannot find username:$username)")
    }
}

loadUserByUserName() 함수는 null 반환을 허용하지 않습니다 따라서 사용자를 찾지 못한 경우 exception을 throw 하도록 합니다.

또한 @Service annotation을 붙여서 spring context가 이를 인지하고 bean으로 생성하게끔 유도합니다.

 

등록 관련된 부분은 완료되었으니 이제 Login form을 아래와 같이 만듭니다.

<body>
  <h1>Registeration</h1>

  <form method="POST" th:action="@{/register}" id="registrationModel">

      <label for="name">Username: </label>
      <input type="text" name="name"/><br/>

      <label for="pass">Password: </label>
      <input type="password" name="pass"/><br/>

      <label for="address">Street: </label>
      <input type="text" name="address"/><br/>

      <label for="phoneNumber">Phone: </label>
      <input type="text" name="phoneNumber"/><br/>

      <input type="submit" value="Register"/>
  </form>

</body>

이 부분은 따로 controller가 필요 없으니 WebConfigure에 간단히 추가합니다.

@Configuration
class WebConfigure : WebMvcConfigurer {
    override fun addViewControllers(registry: ViewControllerRegistry) {
        ...
        registry.addViewController("/login")
    }
}

 

JPA를 이용한 인증 - Configuration 수정

이제 configuration을 수정하여 실제로 userDetailsService와 userDetails이 어떻게 쓰이는지 확인해 보겠습니다.

@Configuration
@EnableWebSecurity
open class SecurityConfig : WebSecurityConfigurerAdapter() {
    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
            .antMatchers("/order/pay","/order/address","/select")
//            .authenticated()
            .access("hasRole('USER')")
            .antMatchers("/h2/**","/", "/**").access("permitAll")
            .and()
            .formLogin()
            .loginPage("/login")
            .defaultSuccessUrl("/",true)            
            .and()
            .logout()
            .logoutSuccessUrl("/")
            .and()
            .csrf()       
    }

    @Autowired
    private lateinit var userDetailsService: UserDetailsService

    @Bean
    fun encoder() = BCryptPasswordEncoder()

    @Throws(Exception::class)
    override fun configure(auth: AuthenticationManagerBuilder) {
        auth
            .userDetailsService(userDetailsService)
            .passwordEncoder(encoder())
    }
}

여기서는 encoder를 bean으로 만들어 spring context에 등록시켜 사용합니다.

따라서 어디서든지 encoder를 주입받아 사용할 수 있게 됩니다.

 

http의 구성

        http.authorizeRequests()
            .antMatchers("/order/pay","/order/address","/select")
//            .authenticated()
            .access("hasRole('USER')")
            .antMatchers("/h2/**","/", "/**").access("permitAll")
            ...

/order/pay, /oder/address, /select로 진입하는 경우 인증이 필요합니다.

이때 인증된 유저의 authority가 'USER'인 경우에만 해당 페이지에 접근 가능합니다.

access 대신에 주석 처리해 놓은 authenticated()로 처리한다면 authority와 상관없이 로그인 여부만으로 판단합니다.

그리고 h2 DB 접근을 위한 /h2나 root 경로의 경우에는 인증 없이도 접근이 가능합니다.

만약 이 순서가 바뀌어 root 경로의 permitAll 조건이 맨 위에 위치한다면 인증 없이 모두 접근 가능해지므로 순서에 유의해야 합니다.

    .and()
        .formLogin()
        .loginPage("/login")
        .defaultSuccessUrl("/",true)

새로운 설정을 시작하기 위해 and()를 추가합니다.

  • loginPage: login이 필요한 경우 localhost:8080/login으로 보냅니다.
  • defaultSuccessUrl: 로그인 후 root 경로로 강제로 이동 하도록 설정합니다.
    • 만약 defaultSuccessUrl을 설정하지 않았다면 아래와 같이 동작합니다.
      • 특정 페이지로 진입하여 로그인이 요청되었다면 로그인후 해당 페이지로 이동
      • 로그인 화면으로 직접 진입했다면 로그인후 root로 이동
    • true로 추가 속성을 주지 않았다면 아래와 같이 동작합니다.
      • 특정 페이지로 진입하여 로그인이 요청되었다면 로그인후 해당 페이지로 이동
      • 로그인 화면으로 직접 진입했다면 param으로 지정된 페이지로 이동

추가적으로 만약 login.html form에서 name과 password의 이름이 각각 "name", "password"가 아니라면 아래처럼 바꿔줘야 합니다. ("name", "password"가 기본값입니다.)

    .and()
        .formLogin()
        .loginPage("/login")
        .defaultSuccessUrl("/",true)
 //       .loginProcessingUrl("/processLogin") //로그인 처리를 하는 경로가 따로 존재할때
 //       .usernameParameter("id") //login.html의 user값의 name이 user가 아닌 다른 값일때
 //       .passwordParameter("pass") //login.html의 password값의 name이 password가 아닌 다른 값일때

 

다시 원문으로 돌아와 logout 부분은 아래와 같습니다.

    .and()
        .logout()
        .logoutSuccessUrl("/")

이렇게 추가해 놓으면 /logout으로 post 된 요청 시 로그아웃 처리와 함께 / 경로로 이동시킵니다.

따라서 로그 아웃이 필요한 곳에는 아래와 같은 form tag를 추가하면 됩니다.

<form method="POST" th:action="@{/logout}" id="logoutForm">
    <input type="submit" value="Logout"/>
</form>

마지막으로 csrf()를 추가합니다.

     .and()
         .csrf()

csrf()를 추가함으로써 해당 공격을 막아줍니다.

 

csrf 처리

Spring security를 이용할 경우 아래와 같이 간단하게 <form>에 hidden 속성을 추가함으로써 csrf 공격에 대비할 수 있습니다.

<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

 

하지만 Themeleaf 사용 시 <form> 요소중 하나가 Thymeleaf 속성을 갖는다면 자동으로 이 hidden filed를 생성합니다.

그래서 아래와 같이 th:action 값을 넣어주고 사용하면 됩니다.

<form method="POST" th:action="@{/login}" id="loginForm">

csrf 공격은 아래 블로그에 설명이 잘 되어있어 링크합니다.

https://itstory.tk/entry/CSRF-%EA%B3%B5%EA%B2%A9%EC%9D%B4%EB%9E%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-CSRF-%EB%B0%A9%EC%96%B4-%EB%B0%A9%EB%B2%95

인증정보 접근

코드를 처리하다 보면 인증 정보에 접근해야 할수 있습니다.

해당 정보로 DB에서 뭘 꺼내 온다든가..

특정 사용자의 경우 추가 작업을 해준다던가..

아래에 여러가지 방법들을 통해서 인증 정보에 접근할 수 있는 방법을 나열합니다.

// Principal 객체를 method에서 주입 받아서 사용
@PostMapping
fun processXXX(..., principal: Principal) {
    val user = principal as User
    ...
}

// Authentication 객체를 method에 주입 받아서 사용
@PostMapping
fun processXXX(..., auth: Authentication) {
    val user = auth.principal as User
    ...
}

// @AuthenticationPrincipal롤 아예 User객체를 주입 받음
@PostMapping
fun processXXX(..., @AuthenticationPrincipal user: User) {
    ...
}

//Security context에서 가져오기
fun XXX() {
   ...
   val user = SecuritycontextHolder.context.authentication.principal as User
   ...
} 

예제에서는 @PostMapping 함수에만 처리했으나 @GetMapping에서 인자로 inject 받아도 동일하게 동작합니다.

또한 마지막 방법의 장점은 어떤 코드에서도 호출해서 가져 올수 있다는 점 입니다.

 

H2 접속 불가 처리

실제로 spring security 적용하면 localhost:8080/h2로 h2 db에 접근할 수 없습니다.

실제 데이터가 들어가는지 봐야 하는데 말이죠..

따라서 이를 허용하려면 csrf를 off 시켜야 합니다.

물론 테스트 용도로만 사용해야 합니다.

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
            .antMatchers("/order/pay","/order/address","/select")
//            .authenticated()
            .access("hasRole('USER')")
            .antMatchers("/h2/**","/", "/**").access("permitAll")
            .and()
            .formLogin()
            .loginPage("/login")
            .defaultSuccessUrl("/",true)
            .and()
            .logout()
            .logoutSuccessUrl("/")
            .and()
//            .csrf()

        //H2 console에 접속하기 위한 설정
        http.headers().frameOptions().disable()
        http.csrf().disable()
    }

 

결론

JPA를 이용하여 간단하게 사용자 등록 및 로그인 과정을 설명했습니다만, JdbcTemplate으로 사용할 수도 있고 LDAP을 이용하여 로그인할 수 도 있습니다.

물론 이런 부분들은 조금만 검색해 보면 금방 찾을 수 있으리라 믿습니다.

실제 security 관련 reference는 아래 사이트에서 확인할 수 있습니다.

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#kotlin-config

 

반응형