[Spring Security] JPA를 활용한 로그인 구현

JPA를 활용해서 로그인 시스템을 구현하자

Login 페이지를 띄워보자

  • SecurityConfig
    • @EnableWebSecurity : 스프링 시큐리티의 웹 보안 기능을 활성화한다.
    • filterChain(HttpSecurity http) : 스프링 시큐리티의 보안 필터 체인을 설정하며, HttpSecurity 객체를 사용해 인증 및 권한 설정을 정의한다.
      • http.formLogin().loginPage("/custom/login").permitAll(); : form 기반의 커스텀 로그인 페이지를 사용하며, 누구나 접근 가능하게 설정한다.
      • http.authorizeRequests().anyRequest().authenticated() : 다른 모든 경로들은 인증된 사용자만 접근할 수 있도록 제한한다.
      @Configuration
      @EnableWebSecurity
      public class SecurityConfig {
        
          @Bean
          public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
              
              http.formLogin()
                      .loginPage("/custom/login") 
                      .permitAll(); 
        
              http.authorizeRequests()
                      .anyRequest()
                      .authenticated();
        
              return http.build();
          }
      }
    


  • login.html (커스텀 로그인 페이지)

      <!DOCTYPE html>
      <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
      <head>
          <title>Please Log In</title>
      </head>
      <body>
      <h1>Please Log In</h1>
      <div th:if="${param.error}">
          Invalid username and password.</div>
      <div th:if="${param.logout}">
          You have been logged out.</div>
      <form th:action="@{/custom/login}" method="post">
          <div>
              <input type="text" name="username" placeholder="Username"/>
          </div>
          <div>
              <input type="password" name="password" placeholder="Password"/>
          </div>
          <input type="submit" value="Log in" />
      </form>
      </body>
      </html>
    


  • AuthenticationController
    • 로그인 페이지를 제공하는 컨트롤러
    • @GetMapping("/custom/login") : /custom/login 경로에 대한 GET 요청을 처리하며, 로그인 페이지를 반환한다.
    • login() : 뷰 이름 login을 반환하고, ViewResolver가 이름에 맞는 Thymeleaf 템플릿을 찾아서 렌더링한다.
      @Controller
      public class AuthenticationController {
          @GetMapping("/custom/login")
          String login() {
              return "login";
          }
      }
    


  • localhost:8080 실행 시 localhost:8080/custom/login 으로 로그인 페이지가 요청된다. image


Login.html에서 POST 요청을 날렸을 때, Security 인증 처리가 되도록 하자

  • User 엔터티 (사용자 엔터티)
    • DB에 저장할 사용자 정보 테이블
    • authorities : 사용자가 가지는 권한을 나타내는 Authority 리스트
      • @OneToMany(mappedBy = "user", fetch = FetchType.EAGER) : 사용자와 권한을 연결하며, 즉시 로딩을 사용하여 권한 정보를 가져온다.
      @Entity
      @Getter
      public class User {
        
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Integer id;
            
          private String username;
          private String password;
        	
          @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
          private List<Authority> authorities;
      }
    


  • Authority 엔터티 (권한 엔터티)
    • 개별 사용자(User)에 대한 권한 정보를 가진 테이블
    • name : 권한 종류 (ex. read, write..)
    • user : 각 권한은 특정 사용자에게 종속되며, ManyToOne 관계로 사용자와 연결된다.
      @Entity
      @Getter
      public class Authority {
        
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Integer id;
        
          private String name;
        
          @ManyToOne
          @JoinColumn(name = "user")
          private User user;
      }
    


  • Product 엔터티 (상품 엔터티)
    • 상품 정보를 관리한다.
      @Entity
      @Getter
      @NoArgsConstructor
      public class Product {
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Integer id;
        
          private String name;
        
          private double price;
        
          public Product(String name, double price) {
              this.name = name;
              this.price = price;
          }
      }
    
  • Database 생성하기
    • create database spring
    • schema

        CREATE TABLE IF NOT EXISTS `spring`.`user` (
            `id` INT NOT NULL AUTO_INCREMENT,
            `username` VARCHAR(45) NOT NULL,
            `password` TEXT NOT NULL,
            PRIMARY KEY (`id`));
              
        CREATE TABLE IF NOT EXISTS `spring`.`authority` (
            `id` INT NOT NULL AUTO_INCREMENT,
            `name` VARCHAR(45) NOT NULL,
            `user` INT NOT NULL,
            PRIMARY KEY (`id`));
              
        CREATE TABLE IF NOT EXISTS `spring`.`product` (
            `id` INT NOT NULL AUTO_INCREMENT,
            `name` VARCHAR(45) NOT NULL,
            `price` VARCHAR(45) NOT NULL,
            PRIMARY KEY (`id`));
      


    • data

        INSERT IGNORE INTO `spring`.`user` (`id`, `username`, `password`) VALUES ('1', 'gugu', '$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG');
              
        INSERT IGNORE INTO `spring`.`authority` (`id`, `name`, `user`) VALUES ('1', 'READ', '1');
        INSERT IGNORE INTO `spring`.`authority` (`id`, `name`, `user`) VALUES ('2', 'WRITE', '1');
              
        INSERT IGNORE INTO `spring`.`product` (`id`, `name`, `price`) VALUES ('1', 'Black noodle', '10');
      


    • db 조회 결과 image
  • SecurityConfigPasswordEncoder 빈 추가하기
    • BCryptPasswordEncoder : 스프링 시큐리티에서 제공하는 비밀번호 암호화 클래스
    • passwordEncoder() : BCryptPasswordEncoder 인스턴스를 생성하여 빈으로 등록한다.
          @Bean
          public PasswordEncoder passwordEncoder() {
              return new BCryptPasswordEncoder();
          }
    


  • UserRepository
    • findUserByUsername(String username) : username 컬럼을 기준으로 사용자 정보를 조회하는 메서드
      @Repository
      public interface UserRepository extends JpaRepository<User, Integer> {
          Optional<User> findUserByUsername(String username);
      }
    


  • UserDetailsServiceloadUserByUsername을 사용하기 위해 JPAUserDetailsService 생성하기
    • UserDetailsService 인터페이스
      public interface UserDetailsService {
          UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
      }
    


    • JpaUserDetailsService
      • UserDetailsService 인터페이스를 구현한 커스텀 클래스
      • 조회된 User 객체를 UserDetails 타입으로 저장되어야 시큐리티가 사용자 정보를 사용 가능하다. → UserUserDetails로 래핑해주기 위한 CustomUserDetails를 추가하자.
        @Service
        @RequiredArgsConstructor
        public class JpaUserDetailsServiceimplements UserDetailsService {
                  
            private final UserRepository userRepository;
              
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                User user = userRepository.findUserByUsername(username)
                        .orElseThrow(() -> new UsernameNotFoundException("해당하는 user가 없음"));
                              
                // user -> username이 gugu일 경우, User 엔티티 하나가 반환됨
              
                return new CustomUserDetails(user);
            }
      


  • CustomUserDetails
    • UserDetails 인터페이스를 구현한 클래스
    • User 객체를 스프링 시큐리티에서 사용할 수 있도록 UserDetails로 변환한다.
    • UserDetails : 스프링 시큐리티가 사용자 정보를 관리하고 인증을 처리할 때 사용하는 인터페이스
    • getAuthorities() : 사용자의 권한목록을 SimpleGrantedAuthority 객체로 변환하여 반환한다.
      • GrantedAuthority : 사용자 권한을 나타내는 인터페이스로, SimpleGrantedAuthority를 구현체로 갖는다.
      @AllArgsConstructor
      public class CustomUserDetails implements UserDetails {
        
          private final User user;
            
          @Override
          public Collection<? extends GrantedAuthority> getAuthorities() {
              return user.getAuthorities().stream()
                      .map(authority -> new SimpleGrantedAuthority(authority.getName()))
                      .collect(Collectors.toList());             
          }
        
          @Override
          public String getPassword() {
              return user.getPassword();
          }
        
          @Override
          public String getUsername() {
              return user.getUsername();
          }
        
          @Override
          public boolean isAccountNonExpired() {
              return true;
          }
        
          @Override
          public boolean isAccountNonLocked() {
              return true;
          }
        
          @Override
          public boolean isCredentialsNonExpired() {
              return true;
          }
        
          @Override
          public boolean isEnabled() {
              return true;
          }
      }
    


  • 로그인 실행 시, 에러는 안뜬다!
    • gugu, 12345로 로그인 하면 된다. image

로그인 성공 시, Products.html을 보여주도록 하자

  • products.html

      <!DOCTYPE html>
      <html lang="en" xmlns:th="http://www.thymeleaf.org">
      <head>
        <meta charset="UTF-8">
        <title>Products</title>
      </head>
      <body>
      <h2 th:text="'안녕하세요, ' + ${username} + '!'" />
        
      <form th:action="@{/custom/logout}" method="post">
        <input type="submit" value="Logout" />
      </form>
        
      <table>
        <thead>
        <tr>
          <th> 상품명 </th>
          <th> 가격 </th>
        </tr>
        </thead>
        <tbody>
        <tr th:if="${products.empty}">
          <td colspan="2"> 상품이 존재하지 않습니다. </td>
        </tr>
        <tr th:each="product : ${products}">
          <td><span th:text="${product.name}"> 상품명 </span></td>
          <td><span th:text="${product.price}"> 가격 </span></td>
        </tr>
        </tbody>
      </table>
      </body>
      </html>
        
    


  • SecurityConfigSecurityFilterChaindefaultSuccessUrl 추가하기
    • defaultSuccessUrl("/products", true) : 인증이 성공했을 때 리다이렉트할 URL
          @Bean
          public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            
              http.formLogin()
                      .loginPage("/custom/login")
                      **.defaultSuccessUrl("/products", true)**
                      .permitAll();
        
              http.authorizeRequests()
                      .anyRequest()
                      .authenticated();
        
              return http.build();
          }
    


  • ProductController
    • username은 제외하고 테스트 먼저 해보기 위해서 products.html에서 username부분 주석 처리하기!
      @Controller
      public class ProductController {
        
          @GetMapping("/products")
          public String showProductPage(Model model) {
            
              // DB에서 조회된 product 데이터
              List<Product> products = new ArrayList<>();
              products.add(new Product("햄버거", 500));
        
              model.addAttribute("products", products);
              return "products";
          }
      }
    


  • 로그인 성공시 products.html를 보여준다. image

Authentication을 사용해서 username이 뜨게 하자

  • ProductController
    • Authentication -> 현재 인증된 사용자의 정보를 담고 있는 객체(token)
      • 컨트롤러 메서드에서 Authentication을 파라미터로 받으면 스프링 시큐리티가 자동으로 현재 로그인된 사용자의 인증 정보를 주입한다.
    • authentication.getName() : Authentication 객체에서 로그인한 사용자의 username을 취득한다.
      @Controller
      public class ProductController {
        
          @GetMapping("/products")
          public String showProductPage(Model model, Authentication authentication) {
                
              String username = authentication.getName();
              model.addAttribute("username", username);
                
              List<Product> products = new ArrayList<>();
              products.add(new Product("햄버거", 500));
        
              model.addAttribute("products", products);
              return "products";
          }
      }
    


  • 로그인 실행 시 products.htmlusername이 뜬다. image

로그아웃

  • SecurityConfig에 로그아웃 설정
    • http.logout().logoutUrl("/custom/logout") : 로그아웃을 처리하는 URL 경로를 지정한다. (POST : custom/logout)
    • http.logout().logoutSuccessUrl(”/main”) : 로그아웃이 성공적으로 이루어진 후 사용자가 리다이렉트될 페이지를 지정한다.
    • http.authorizeRequests().mvcMatchers("/main").permitAll(); : 메인 페이지(main.html)는 별도의 인증없이 접근할 수 있어야 한다.
          @Bean
          public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            
              http.formLogin()
                      .loginPage("/custom/login")
                      .defaultSuccessUrl("/products", true)
                      .permitAll();
        
              http.logout().logoutUrl("/custom/logout");
              http.logout().logoutSuccessUrl("/main");
              http.authorizeRequests().mvcMatchers("/main").permitAll();
        
              http.authorizeRequests()
                      .anyRequest()
                      .authenticated();
        
              return http.build();
          }
    


  • AuthenticationController (LoginController)
    • SecurityContextLogoutHandler를 사용해 실제 로그아웃 작업을 수행하고, 로그아웃 후 /main 페이지로 리다이렉트한다.
      • 서버가 브라우저에게 Location 헤더에 main.html 경로를 실어서 응답해준다.
      • 브라우저는 Location 헤더의 값을 기반으로 리다이렉트를 수행한다.
    • SecurityConfig에서 로그아웃이 설정되어 있기 때문에 컨트롤러 없이도 기본 로그아웃 기능은 동작한다.
      • 추가적인 처리가 필요한 경우 컨트롤러를 사용하여 구현할 수 있다 .
      @Controller
      public class AuthenticationController {
        
          @GetMapping("/custom/login")
          String login() {
              return "login";
          }
        
          @PostMapping("/custom/logout")
          public String logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
            
              SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler();
        
              logoutHandler.logout(request, response, authentication);
              // 그 외 다른 처리가 필요한 코드도 작성 가능
                
              return "redirect:/main";
          }
      }
    


  • MainPageController
    • 로그아웃 후 이동할 main 페이지를 처리하는 컨트롤러
      @Controller
      public class MainPageController {
        
          @GetMapping("/main")
          public String main() {
              return "main";
          }
      }
    


  • main.html

      <!DOCTYPE html>
      <html lang="en" xmlns:th="http://www.thymeleaf.org">
      <head>
          <meta charset="UTF-8">
          <title>main</title>
      </head>
      <body>
        
      <a href="/products">상품 조회</a>
      <a href="/login">로그인</a>
        
      </body>
      </html>
    


  • 로그인 및 로그아웃 동작
    • 로그인시 image

    • 로그아웃시 main.html로 이동한다. image

MainPageController를 MvcConfig로 대체

  • 기존의 MainPageController/main 경로에 대한 요청을 처리하고, main.html을 반환하는 역할을 한다.
    • 단순한 작업을 처리하기 위해 컨트롤러 클래스를 추가하는 것은 불필요하다!
  • WebMvcConfigurer : Spring MVC의 기본설정을 확장하거나 커스터마이징할 때 사용된다.
    • 주로 @Configuration 클래스 내에서 구현하며, 다양한 메서드를 오버라이딩하여 MVC 동작을 원하는 대로 설정할 수 있다.
    • addViewControllers() : ViewControllerRegistry를 통해 특정 URL 요청을 별도의 컨트롤러 없이 뷰로 바로 매핑할 수 있다.
      • /main 경로에 대한 요청이 들어오면 main.html 뷰를 반환한다.
      @Configuration
      public class MvcConfig implements WebMvcConfigurer {
          @Override
          public void addViewControllers(ViewControllerRegistry registry) {
              registry.addViewController("/main").setViewName("main");
          }
      }
    


회원가입

  • UserService 생성
    • 편의상 인터페이스와 dto를 생략했다.
    • PasswordEncoder : SecurityConfig에서 스프링 빈으로 등록한 BCryptEncoder가 주입된다.
    • createUser(User user) : 회원 가입 처리 메서드
      • passwordEncoder.encode(user.getPassword()) : 입력받은 비밀번호를 BCryptEncoder로 인코딩을 수행한다.
      • user.encodePassword(encodedPassword) : password의 값을 변경하는 setter 메서드
      • userRepository.save(user) : 컨트롤러에게 전달받은 사용자 객체를 DB에 저장한다.
      @Service
      @RequiredArgsConstructor
      public class UserService { 
        
          private final UserRepository userRepository;
          private final PasswordEncoder passwordEncoder;
        
          public void createUser(User user) {
        
              String encodedPassword = passwordEncoder.encode(user.getPassword());
        
              user.encodePassword(encodedPassword);
              //user.getAuthorities().add()
        
              userRepository.save(user);
          }
      }
    


  • User
    • encodePassword(String encodedPassword) : 비밀번호를 암호화된 형태로 변경한다.
      @Entity
      @Getter
      @NoArgsConstructor
      public class User {
        
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Integer id;
        
          private String username;
          private String password;
        
          // 사용자의 권한
          @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
          private List<Authority> authorities;
        
          public User(String username, String password) {
              this.username = username;
              this.password = password;
          }
        
          public void encodePassword(String encodedPassword) {
              this.password = encodedPassword;
          }
      }
    


  • signup.html

      <!DOCTYPE html>
      <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
      <head>
          <title>Please Sign Up</title>
      </head>
      <body>
      <h1>Please Sign Up</h1>
      <form th:action="@{/custom/signup}" method="post">
          <div>
              <input type="text" name="username" placeholder="Username"/>
          </div>
          <div>
              <input type="password" name="password" placeholder="Password"/>
          </div>
          <input type="submit" value="Sign Up" />
      </form>
      </body>
      </html>
    


  • SecurityConfig
    • http.authorizeRequests().mvcMatchers(”/custom/signup”).permitAll() : 회원가입 페이지는 로그인 여부와 상관없이 모든 사용자가 접근 가능하게 된다.
      • mvcMatchers() : 특정 경로에 대한 매핑을 설정하는 데 사용된다.
      @Bean
          public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        
              http.formLogin()
                      .loginPage("/custom/login")
                      .defaultSuccessUrl("/products", true)
                      .permitAll();
        
              http.logout().logoutUrl("/custom/logout");
              http.logout().logoutSuccessUrl("/main");
              http.authorizeRequests().mvcMatchers("/main").permitAll();
        
              // 회원가입
              http.authorizeRequests()
                      .mvcMatchers("/custom/signup")
                      .permitAll();
        
              http.authorizeRequests()
                      .anyRequest()
                      .authenticated();
        
              return http.build();
          }
    


  • AuthenticationController
    • showSignUpForm() : /custom/signup 경로로 GET 요청이 들어오면 signup.html 페이지를 반환하여 회원가입 폼을 보여준다.
    • signUp(@RequestParam String username, @RequestParam String password) : POST 요청이 들어오면, usernamepassword를 받아 새로운 User를 생성하고, createUser 메서드를 호출해 회원가입을 처리한다.
      @Controller
      public class AuthenticationController {
        
          private final UserService userService;
        
          public AuthenticationController(UserService userService) {
              this.userService = userService;
          }
        
          //...
        
          @GetMapping("/custom/signup")
          public String showSignUpForm() {
              return "signup";
          }
        
          @PostMapping("/custom/signup")
          public String signUp(@RequestParam String username,
                               @RequestParam String password) {
              System.out.println("username = " + username + ", password = " + password);
              User user = new User(username, password);
              userService.createUser(user);
              return "redirect:/main";
          }
      }
    


상품 페이지 로직 마저 구현

  • ProductController에 작성된 코드 수정해서 DB에서 데이터 가져올 수 있도록 하기
    • ProductRepository

        @Repository
        public interface ProductRepository extends JpaRepository<Product, Integer> {
        }
      


    • ProductController

        @Controller
        @RequiredArgsConstructor
        public class ProductController {
              
            private final ProductRepository productRepository;
              
            @GetMapping("/products")
            public String showProductPage(Model model, Authentication authentication) {
              
                String username = authentication.getName();
                model.addAttribute("username", username);
              
                // DB에서 조회된 product 데이터
                List<Product> products = productRepository.findAll();
                model.addAttribute("products", products);
                return "products";
            }
        }
      




우리 FISA 수업을 참고하였습니다.

© 2021. All rights reserved.

yaejinkong의 블로그