[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
으로 로그인 페이지가 요청된다.
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 조회 결과
- SecurityConfig에 PasswordEncoder 빈 추가하기
- 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); }
- UserDetailsService의
loadUserByUsername
을 사용하기 위해 JPAUserDetailsService 생성하기- UserDetailsService 인터페이스
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
- JpaUserDetailsService
- UserDetailsService 인터페이스를 구현한 커스텀 클래스
- 조회된 User 객체를 UserDetails 타입으로 저장되어야 시큐리티가 사용자 정보를 사용 가능하다. → User를 UserDetails로 래핑해주기 위한 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
로 로그인 하면 된다.
로그인 성공 시, 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>
- SecurityConfig의 SecurityFilterChain에
defaultSuccessUrl
추가하기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를 보여준다.
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"; } }
- Authentication -> 현재 인증된 사용자의 정보를 담고 있는 객체(token)
- 로그인 실행 시 products.html에 username이 뜬다.
로그아웃
- 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"; } }
- SecurityContextLogoutHandler를 사용해 실제 로그아웃 작업을 수행하고, 로그아웃 후
- 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>
- 로그인 및 로그아웃 동작
로그인시
로그아웃시
main.html
로 이동한다.
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 요청이 들어오면, username과 password를 받아 새로운 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 수업을 참고하였습니다.