ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [1단계] Spring MVC 웹 앱 9일차. 스프링 시큐리티의 적용
    레거시/레거시-마이 셀파 리부트 2018. 6. 28. 21:00
    반응형

    *본 글은 필자가 진행한 프로젝트 '마이 셀파'의 작업 일기입니다. 재밌게 봐주세요. 코드와 작업 진행 사항은 https://github.com/gurumee92/MySelpaReboot 에서 확인해볼 수 있습니다.


    마이 셀파 리부트 1단계 Spring MVC를 활용한 웹 앱

    9일차 스프링 시큐리티의 적용


    오늘은 스프링 시큐리티 적용 작업을 진행하였습니다. 참고적으로 여태까지 써왔던 모델이나 DB등에 시큐리티를 적용하는데 있어 어려움이 있어서 구조를 변경시켰습니다. 그럼 오늘도 시큐리티에 대한 간단한 얘기부터 하고 바로 작업을 진행하도록 하죠. 다음 내용은 위키북스 책 '스프링 철저 입문'을 참고하였습니다.


    스프링 시큐리티


    이름 그대로 스프링 프레임워크로 앱을 만들 때 보안 기능을 구현할 때 사용되는 모듈입니다. 주로 서블릿 컨테이너에 배포하는 웹 애플리케이션의 보안 기능을 구현할 때 사용됩니다. 저처럼 혼자 만드는 사람들에게는 필요는 없지만 서버에 배포되며 사람들의 귀중한 정보를 다루는 애플리케이션에게는 꼭 필요하겠죠? 다음은 이 모듈의 특징입니다.


    • 다양한 옵션을 제공 
    • 다양한 확장점을 제공

    시큐리티는 기본 구현 클래스의 동작 방식을 커스터마이징할 수 있는 다양한 옵션들을 제공한다고 합니다. 따라서 기본 동작 방식이 보안 요구 사항을 만족시키지 않는다면 개발자가 요구 사항에 맞게 다시 설정이 가능합니다. 그리고 다른 방법으로는 기본 구현 클래스를 확장하는 방법이 있습니다. 시큐리티의 대표적인 기능은 다음과 같습니다.


    • 인증
    • 인가
    • CSRF(Cross site Request Forgery 크로스 사이트 요청 변조) 대첵
    • 세션 관리
    • 브라우저 보안

    바로 작업에 들어가보겠습니다.


    모델과 DB구조 변경


    일단 스프링 시큐리티는 유저의 ROLE 그러니까 사용자의 역할에 따라 앱을 다르게 제어해주는 기능을 제공합니다. 마이 셀파는의 유저는 is_master 필드에 따라 마스터인지 일반 유저인지 판별하는데요. 이 필드를 이름은 role

    타입은 문자형으로 변경시켜줍니다. 일반 유저는 "USER" 마스터는 "MASTER" 라는 값을 넣어서 유저의 역할 정보를 저장할 것입니다.


     FILED 

     PRIMARY KEY

     DESCRIPTION

     id

     O 

     varchar(255), not null,  

     pw

     

     varchar(255), not null,  

     phone

     

     varchar(255), not null,  

     carno

     

     varchar(255), not null,  

     role

     

     varchar(255), not null,  default "USER"

     

    참고!

    원래는 USER 테이블과 ROLE 테이블을 나누어서 ROLE 테이블이 유저 테이블의 id를 참조하도록 만들고 관리해야 한다고 합니다. 다만 저는 변경하기 쉽게 이렇게 바꾸었습니다.


    참고!

    이렇게 구조를 바꾸기 위해서는 데이터를 모두 삭제하고 다시 만드는 것이 편합니다. 데이터 쿼리는 3일차 작업을 참고해주세요!


    DB가 변경됨에 따라 JPA Entity 클래스도 바뀌어야 합니다. 다음은 바뀐 User 클래스입니다.


    src/main/kotlin/[프로젝트]/domain/model/User.kt 

    package com.gurumee.myselpa.domain.model

    import javax.persistence.*
    import java.io.Serializable

    enum class Role{
    MASTER, USER
    }

    @Entity
    @Table(name = "user")
    internal data class User(
    @Id
    @Column(name = "id")
    var id: String,

    @Column(name = "pw")
    var pw: String,

    @Column(name = "phone")
    var phone: String,

    @Column(name = "carno")
    var carNo: String,

    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    var role: Role,

    @OneToOne(fetch = FetchType.LAZY,
    mappedBy = "user",
    cascade = arrayOf(CascadeType.ALL))
    var reservation: Reservation?
    ) : Serializable{

    fun getMyCarReservationInfo(): String =
    if (reservation == null)
    "예약 정보가 없습니다."
    else
    """차량 번호 : ${reservation!!.user.carNo}
    주차장 이름 : ${reservation!!.parkingLot.name}
    주차한 공간 : ${reservation!!.sector}"""
    }

    먼저 Role이라는 enum 클래스를 정의해주었습니다. 

    enum class Role{
    MASTER, USER
    }

    이 상수 클래스는 MASTER, USER 이 두개의 값만을 가집니다. 그리고 isMaster 프로퍼티를 role로 바꿔주었습니다.


    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    var role: Role,

    여기서 @Enumerated() 애노테이션은 이 필드의 상수 클래스인 Role과 DB의 role(varchar)와 연동될 수 있게 만들어줍니다. 


    스프링 시큐리티 적용 build.gradle



    먼저 스프링 시큐리티 적용을 위해 DB와 모델을 변경시켜주었습니다. 이제 build.gradle에서 시큐리티 모듈을 불러오도록 작성해주면 됩니다.


    build.gradle

    //이전과 동일


    dependencies {
    compile('org.springframework.boot:spring-boot-starter-security') //스프링 시큐리티 모듈
    compile('org.thymeleaf.extras:thymeleaf-extras-springsecurity4') //타임리프-시큐리티 연동
    //이전과 동일
    }

    그 후 프로젝트와 동기화시켜주면 됩니다. 그 후 ...Application.kt 가 위치하는 이하 디렉토리에 시큐리티를 설정하는 WebSecurityConfig.kt를 작성하면 됩니다. 제가 작업한 시큐리티 설정 파일은 다음과 같습니다.


    시큐리티 적용 : 사용자 인증


    src/main/koltin/[프로젝트]/WebSecurityConfig.kt

    package com.gurumee.myselpa

    import com.gurumee.myselpa.domain.service.user.ReservationUserDetails
    import com.gurumee.myselpa.domain.service.user.UserService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.context.annotation.Bean
    import org.springframework.context.annotation.Configuration
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
    import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
    import org.springframework.security.config.annotation.web.builders.HttpSecurity
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
    import org.springframework.security.crypto.password.PasswordEncoder

    @Configuration
    @EnableWebSecurity                                 //이 애노테이션은 Web모듈과 Security모듈을 연결시켜주는 애노테이션입니다.

    @EnableGlobalMethodSecurity(prePostEnabled = true) //이 애노테이션은 애플리케이션의 @PreAuthorize 기능을 활성화 시켜줍니다.
    internal class WebSecurityConfig : WebSecurityConfigurerAdapter() {

    @Autowired
    lateinit var userService: UserService

    @Bean
    fun passwordEncoder(): PasswordEncoder {
    return BCryptPasswordEncoder()                //이 애노테이션은 보안에 적용될 비밀번호 알고리즘에 관한 것입니다.

    }

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
    http.authorizeRequests()
    .antMatchers("/js/**", "/css/**", "/join/**").permitAll() //js, css, 회원가입 URL /join의 접근을 허용십니다.
    .antMatchers("/**").authenticated() //나머지 경로는 인증이 필요합니다.
    .and()
    .formLogin()                                                //로그인에 관한 설정입니다.
    .loginPage("/login").loginProcessingUrl("/")                //처리할 페이지는 /login입니다.

    .usernameParameter("username")                              //id, pw입니다.
    .passwordParameter("password")
    .defaultSuccessUrl("/user", true)                           //인증이 성공되면 /user 경로로
    .failureUrl("/login?error=true")                            //실패하면 다음 경로로 이동합니다.
    .permitAll()
    .and()
    .logout()
    }

    @Throws(Exception::class)
    override fun configure(auth: AuthenticationManagerBuilder?) {
    auth!!.userDetailsService<UserService>(userService).passwordEncoder(passwordEncoder()) //인증을 할 때 지정된 UserService 객체와 passwordEncoder 방식을 사용합니다.


    }
    }

    코드 설명은 위의 주석을 참고해주세요. 그리고 나서 개발자가 만든 User 클래스를 시큐리티를 적용시키려면 UserDetails라는 것을 구현하는 클래스를 만들어주어야 합니다. 제가 만든 ReservationUserDetails는 다음과 같습니다.


    src/main/kotlin/[프로젝트]/domain/service/user/ReservationUserDetails.kt


    package com.gurumee.myselpa.domain.service.user

    import com.gurumee.myselpa.domain.model.User
    import org.springframework.security.core.GrantedAuthority
    import org.springframework.security.core.authority.AuthorityUtils
    import org.springframework.security.core.userdetails.UserDetails

    internal data class ReservationUserDetails(
    val user: User
    ) : UserDetails{
    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {

    return AuthorityUtils.createAuthorityList("ROLE_${user.role.name}")
    }

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

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

    override fun isCredentialsNonExpired(): Boolean {
    return true
    }

    override fun isEnabled(): Boolean {
    return true
    }

    override fun isAccountNonExpired(): Boolean {
    return true
    }

    override fun isAccountNonLocked(): Boolean {
    return true
    }

    }

    참고!


    UserDetails 인터페이스를 구현하면 다음의 메소드들을 재정의해주어야 합니다. 


     메소드명

     설명 

     getAuthorities

     사용자 권한을 프로퍼티 형태로 담습니다. 주의할 점은 "ROLE_"

    형식이어야 합니다.

     getUsername

     사용자 이름을 프로퍼티 형태로 담습니다.

     getPassword 

     사용자 비밀번호를 프로퍼티 형태로 담습니다.

     isCredentialsNonExpired

     계정 잠금, 계정의 유효기간 만료, 자격 정보의 유효기간 만료 확인 등에 대한 메소드들입니다. 저는 구현할 필요 없어서 다 true로 지정해주었습니다.

     isEnabled

     isAccountNonExpired

     isAccountNonLocked


    이제 UserService도 시큐리티에 적용시키게끔 다음과 같이 작성하였습니다.


    src/main/kotlin/프로젝트/domain/service/user/UserService.kt

    package com.gurumee.myselpa.domain.service.user

    import com.gurumee.myselpa.domain.model.Role
    import com.gurumee.myselpa.domain.model.User
    import com.gurumee.myselpa.domain.repository.user.UserRepository
    import org.springframework.beans.factory.annotation.Autowired
    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.Service
    import org.springframework.transaction.annotation.Transactional
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder

    @Service
    @Transactional
    internal class UserService : UserDetailsService {

    @Autowired
    lateinit var userRepository: UserRepository

    override fun loadUserByUsername(username: String): UserDetails {
    val user = userRepository.findById(username)

    return when(user.isPresent){
    true -> ReservationUserDetails(user.get())
    false -> throw UsernameNotFoundException("$username is not found") as Throwable
    }
    }

    fun join(id: String, pw: String, phone: String, carNo: String): Boolean{
    if (id == "" || pw == "" || phone == "" || carNo == "")
    return false

    val user = userRepository.findById(id)

    return when (user.isPresent){
    true -> false
    false-> {
    val encoder = BCryptPasswordEncoder()

    userRepository.save(User(id, encoder.encode(pw), phone, carNo, Role.USER, null))
    true
    }
    }
    }
    }

    참고!

    UserDetailsService인터페이스를 implements하게 되면 loadUserByUsername 이라는 메소드를 꼭 구현하여야 합니다. 이때 유저의 이름으로 DB에서 읽어와 개발자가 만든 UserDetails 클래스를 반환하게끔 만들어야 합니다. 저는 id가 username에 해당하기 때문에 Repository의 기본 메소드 findById를 사용하였습니다.


    참고!

    코틀린에서는 throw도 객체 형태로 반환시킬 수 있습니다. 다만 반환되는게 아니라 그냥 실패하면 예외가 발생한다라고 알아두시면 되겠습니다.


    참고!

    회원 가입시 이전에 썼던 join 메소드가 변경되었습니다. 변경된 부분은 해당하는 아이디의 유저가 존재하지 않을 때 BCryptPasswordEncoder를 생성해서 입력받은 비밀번호를 해당 알고리즘을 통해 인코딩 시켜주는 부분이 추가되었습니다.


    src/main/resources/templates/login/login.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <!-- URL : http://localhost:8080/ -->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    </head>
    <body>
    <div class="title">
    <h1>MySelpa</h1>
    </div>
    <div class="content">
    <p th:if="${param.error}">Error!</p>
    <form th:action="@{/}" method="post">
    <input type="text" id="username" name="username"/> <br/>
    <input type="password" id="password" name="password"/> <br/>
    <input type="submit" value="로그인">
    </form>
    <form th:action="@{/join}" method="get">
    <input type="submit" value="회원가입">
    </form>
    </div>
    </body>
    </html>

    이때 form 안에 input 태그들이 시큐리티에 적용되려면 들어가는 id와 password는 WebSecurityConfig의 configure 메소드에서 호출 중에 usernameParameter("..."), passwordParameter("..")에서 선언했던 이름으로 지정해주어야 합니다.


    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
    http.authorizeRequests()
    ....
    .formLogin()
    .loginPage("/login").loginProcessingUrl("/")
    .usernameParameter("username")
    .passwordParameter("password")

    ....

    }

    이제 LoginController를 다음과 같이 변경하면 됩니다.


    src/main/kotlin/프로젝트/app/login/LoginController.kt

    package com.gurumee.myselpa.app.login

    import com.gurumee.myselpa.domain.service.user.UserService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.stereotype.Controller
    import org.springframework.ui.Model
    import org.springframework.web.bind.annotation.ModelAttribute
    import org.springframework.web.bind.annotation.RequestMapping
    import org.springframework.web.bind.annotation.RequestMethod
    import org.springframework.web.servlet.ModelAndView
    import java.io.Serializable

    @Controller
    @RequestMapping("/login")
    internal class LoginController{

    @RequestMapping(method= arrayOf(RequestMethod.GET))
    fun getToLogin(model: Model): String {

    return "login/login.html"
    }
    }

    이 때 RequestMapping 애노테이션에 들어갈 URL 값은 WebSecurityCOnfig에서 선언했던 .loginPage("...")와 URL이 일치해야 합니다. 자 이제 유저 정보 화면으로 이 인증된 유저를 넘겨주는 작업을 진행하도록 하죠.


    유저 정보 화면으로 보는 인증된 유저 사용법


    먼저 UserController를 다음과 같이 변경하였습니다.


    src/main/kotlin/프로젝트/app/user/UserController.kt

    package com.gurumee.myselpa.app.user

    import com.gurumee.myselpa.domain.model.User
    import com.gurumee.myselpa.domain.service.reservation.ReservationService
    import com.gurumee.myselpa.domain.service.user.ReservationUserDetails
    import com.gurumee.myselpa.domain.service.user.UserService
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.security.core.annotation.AuthenticationPrincipal
    import org.springframework.stereotype.Controller
    import org.springframework.ui.Model
    import org.springframework.web.bind.annotation.PathVariable
    import org.springframework.web.bind.annotation.RequestMapping
    import org.springframework.web.bind.annotation.RequestMethod

    @Controller
    @RequestMapping("user")
    internal class UserController{

    @Autowired
    lateinit var reservationService: ReservationService

    @RequestMapping(method=arrayOf(RequestMethod.GET))
    fun getToUser(model: Model,
    @AuthenticationPrincipal userDetails: ReservationUserDetails): String{
    model.addAttribute("user", userDetails.user)
    return "user/user.html"
    }

    @RequestMapping(method= arrayOf(RequestMethod.POST))
    fun postToUser(model: Model,
    @AuthenticationPrincipal userDetails: ReservationUserDetails): String{
    reservationService.deleteReservationByUser(userDetails.user)

    return "redirect:/user"
    }
    }

    파라미터에 @AuthenticationPrincipal userDetails: ReservationUserDetails 가 추가 되었습니다. 인증된 후 진행되는 페이지에서 유저 정보를 사용하기 위해서는 이렇게 자신이 만든 UserDetails클래스를 위의 애노테이션에 붙여서 넘겨주면 됩니다. 이제는 인증된 유저 정보가 있기 때문에 파라미터로 유저가 가진 예약 번호를 넘겨줄 필요가 없습니다. 따라서 ReservationService에 유저 정보로 삭제시킬수 있는 deleteReservationByUser 메소드를 정의해주었습니다.


    src/main/kotlin/프로젝트/domain/service/reservation/ReservationService.kt

    package com.gurumee.myselpa.domain.service.reservation

    import com.gurumee.myselpa.domain.model.Reservation
    import com.gurumee.myselpa.domain.model.User
    import com.gurumee.myselpa.domain.repository.lot.ParkingLotRepository
    import com.gurumee.myselpa.domain.repository.reservation.ReservationRepository
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.stereotype.Service
    import org.springframework.transaction.annotation.Transactional

    @Service
    @Transactional
    internal class ReservationService {
    @Autowired
    lateinit var reservationRepository: ReservationRepository

    @Autowired
    lateinit var parkingLotRepository: ParkingLotRepository


    fun deleteReservationByUser(user: User){
    reservationRepository.deleteReservationByNo(user.reservation!!.no)
    user.reservation = null
    }


    fun deleteReservationByNo(no: Int){
    reservationRepository.deleteReservationByNo(no)
    }

    fun insertReservation(user: User, lotNo: Int, sectorNo: Int){

    val lot = parkingLotRepository.findById(lotNo).get()


    reservationRepository.save(Reservation(-1, user, lot, sectorNo))
    val reservation = reservationRepository.findReservationByUser(user)
    user.reservation = reservation
    }
    }


    insertReservation 메소드 역시 변경되었습니다. 이렇게 작성한 이유는 DB에는 예약 정보의 no 필드는 자동으로 auto_increment되서 추가되는데 앱에는 -1인 채로 유지되기 때문입니다. 따라서 이 예약 정보를 추가된 직후에 다시 유저 정보를 이용하여 추가한 예약 정보를 찾아서 유저의 reservation에 할당해주었습니다. 따라서 ReservationRepository도 다음과 같이 변경하였습니다.


    src/main/kotlin/[프로젝트]/domain/repository/ReservationRepository.kt

    package com.gurumee.myselpa.domain.repository.reservation

    import com.gurumee.myselpa.domain.model.ParkingLot
    import com.gurumee.myselpa.domain.model.Reservation
    import com.gurumee.myselpa.domain.model.User

    import org.springframework.data.jpa.repository.JpaRepository
    import org.springframework.data.jpa.repository.Modifying
    import org.springframework.data.jpa.repository.Query
    import org.springframework.data.repository.query.Param
    import org.springframework.security.access.prepost.PreAuthorize
    import org.springframework.stereotype.Repository

    @Repository
    internal interface ReservationRepository : JpaRepository<Reservation, Int> {

    @PreAuthorize("hasRole('MASTER') or #reservation.user.id==principal.user.id" )
    @Modifying
    @Query("DELETE FROM Reservation r WHERE r.no=:no")
    fun deleteReservationByNo(@Param("no") no: Int)

    @Query("SELECT r FROM Reservation r WHERE r.user=:user")
    fun findReservationByUser(@Param("user") user: User): Reservation

    }


    그 후에 user.html을 다음과 같이 변경해주면 됩니다.


    src/main/resource/templates/user/user.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <!-- URL : http://localhost:8080/user-->
    <head>
    <meta charset="UTF-8">
    <title>마이 셀파 리부트</title>
    </head>
    <body>
    <div class="title_area">
    <h1 th:text="|${user.id}님|">유저 정보 </h1>
    </div>

    <div sec:authorize="hasRole('MASTER')"
    calss="get_master_area">
    <form th:action="@{'/search'}"
    method="get">
    <input type="submit" value="주차장 관리하기"/>
    </form>
    </div>

    <div sec:authorize="hasRole('USER')"
    class="get_user_area">

    <div class="reservation_info">
    <span th:text="|${user.getMyCarReservationInfo()}|">차량 정보</span>
    </div>

    <form th:if="${user.reservation } eq null"
    th:action="@{'/search'}"
    method="get">
    <input type="submit" value="주차하기"/>
    </form>

    <form th:unless="${user.reservation } eq null"
    th:action="@{'/user/'}"
    method="post">
    <input type="submit" value="출차하기"/>

    </form>
    </div>

    <div class="common_area">
    <form th:action="@{/logout}" method="post">
    <button>로그아웃</button>
    </form>
    </div>
    </body>
    </html>


    여기서 이제 로그인 페이지를 거쳐 인증된 절차를 받는 페이지들은 html 태그에 다음 속성을 추가해주어야 합니다.


    <html xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

    그리고 타임리프 속성 th:if="${is_master}" 즉 모델에서 주입했던 is_master에 따라 동적으로 변경되었던 유저와 마스터의 화면은 다음으로 바꿔주면 됩니다.


    <div sec:authorize="hasRole('MASTER')"
    calss="get_master_area">
        //...
    </div>
    <div sec:authorize="hasRole('USER')"
    class="get_user_area">

       //...
    </div>

    sec:authorize="hasRole('인증값')" 속성을 위와 같이 두면 인증값에 따라 화면을 바꿔주는 파일로 만들어 줄 수 있습니다. 또 하나 로그아웃 작업은 하지 않았지만 이것은 스프링 시큐리티가 대신 작업을 해줍니다. 따라서 로그아웃이 필요한 페이지에 이 버튼만 추가해주면 됩니다. 


    <form th:action="@{/logout}" method="post">
    <button>로그아웃</button>
    </form>


    검색 화면과 상세 정보 페이지도 이처럼 바꿔주면 됩니다. 원리는 똑같으니 설명은 생략하겠습니다. 이렇게 해서 9일차 작업 스프링 시큐리티 적용 작업도 끝났습니다. 확실히 이해가 부족한 모듈들은 적용하는데 애를 먹는 것 같습니다. 지나고 보면 그렇게 어려웠던 문제가 아니었는데 시간이 오래 걸리네요... 아무튼 오늘로써 프로젝트의 Back-end 작업은 끝났습니다. 내일은 이제 Bootstrap으로 UI를 아름답게 바꾸는 작업을 진행하겠습니다. 고생하셨습니다! 

Designed by Tistory.