dependencies {
(생략)
// 스프링 시큐리티를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
// 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// 스프링 시큐리티를 테스트하기 위한 의존성 추가
testImplementation 'org.springframework.security:spring-security-test'
}
package me.ansoohyeon.springbootdeveloper.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails { // UserDetails를 상속받아 인증 객체로 사용
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Builder
public User(String email, String password, String auth) {
this.email = email;
this.password = password;
}
@Override // 권한 반환
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
// 계정 만료 여부 반환
@Override
public boolean isAccountNonExpired(){
// 만료되었는지 확인하는 로직
return true; // true -> 만료되지 않음
}
// 계정 잠금 여부 반환
@Override
public boolean isAccountNonLocked(){
return true; // true -> 잠금되지 않음
}
// 패스워드 만료 여부 반환
@Override
public boolean isCredentialsNonExpired(){
return true; // true -> 만료되지 않음
}
// 계정 사용 가능 여부 변환
@Override
public boolean isEnabled(){
return true; // true -> 사용 가능
}
}
package me.ansoohyeon.springbootdeveloper.repository;
import me.ansoohyeon.springbootdeveloper.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // 이메일로 사용자 정보를 가져옴
}
package me.ansoohyeon.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
// 스프링 시큐리티에서 사용자 정보를 가져오는 인터페이스
public class UserDetailService implements UserDetailsService {
private final UserRepository userRepository;
// 사용자 이름(email)으로 사용자 정보를 가져오는 메소드
@Override
public User loadUserByUsername(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException((email)));
}
}
package me.ansoohyeon.springbootdeveloper.config;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.service.UserDetailService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;
@RequiredArgsConstructor
@Configuration
public class WebSecurityConfig {
private final UserDetailService userService;
// 스프링 시큐리티 기능 비활성화
@Bean
public WebSecurityCustomizer configure(){
return (web) -> web.ignoring()
.requestMatchers(toH2Console())
.requestMatchers("/static/**");
}
// 특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
.authorizeRequests() // 인증, 인가 설정
.requestMatchers("/login", "/signup", "/user").permitAll()
.anyRequest().authenticated()
.and()
.formLogin() // 폼 기반 로그인 설정
.loginPage("/login")
.defaultSuccessUrl("/articles")
.and()
.logout() // 로그아웃 설정
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.and()
.csrf().disable() // csrf 비활성화 -> 실습을 위해 잠깐 비활성화!!
.build();
}
// 인증 관리자 관련 설정
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService)
throws Exception{
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userService) // 사용자 정보 서비스 설정
.passwordEncoder(bCryptPasswordEncoder)
.and()
.build();
}
// 패스워드 인코더로 사용할 빈 등록
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
이 부분이 굉장히 빡셈.... 🤯
package me.ansoohyeon.springbootdeveloper.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
package me.ansoohyeon.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.dto.AddUserRequest;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public Long save(AddUserRequest dto){
return userRepository.save(User.builder()
.email(dto.getEmail())
// 패스워드 암호화
.password(bCryptPasswordEncoder.encode(dto.getPassword()))
.build()).getId();
}
}
package me.ansoohyeon.springbootdeveloper.controller;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.dto.AddUserRequest;
import me.ansoohyeon.springbootdeveloper.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
@RequiredArgsConstructor
@Controller
public class UserApiController {
private final UserService userService;
@PostMapping("/user")
public String signup(AddUserRequest request){
userService.save(request); // 회원 가입 메소드 호출
return "redirect:/login"; // 회원 가입이 완료된 후 로그인 페이지로 이동
}
}
package me.ansoohyeon.springbootdeveloper.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UserViewController {
@GetMapping("/login")
public String login(){
return "login";
}
@GetMapping("/signup")
public String signup(){
return "signup";
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">LOGIN</h2>
<p class="text-white-50 mt-2 mb-5">서비스를 사용하려면 로그인을 해주세요!</p>
<div class = "mb-2">
<form action="/login" method="POST">
<input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
<div class="mb-3">
<label class="form-label text-white">Email address</label>
<input type="email" class="form-control" name="username">
</div>
<div class="mb-3">
<label class="form-label text-white">Password</label>
<input type="password" class="form-control" name="password">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<button type="button" class="btn btn-secondary mt-3" onclick="location.href='/signup'">회원가입</button>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원 가입</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: linear-gradient(to right, rgba(254, 238, 229, 1), rgba(229, 193, 197, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">SIGN UP</h2>
<p class="text-white-50 mt-2 mb-5">서비스 사용을 위한 회원 가입</p>
<div class = "mb-2">
<form th:action="@{/user}" method="POST">
<!-- 토큰을 추가하여 CSRF 공격 방지 -->
<input type="hidden" th:name="${_csrf?.parameterName}" th:value="${_csrf?.token}" />
<div class="mb-3">
<label class="form-label text-white">Email address</label>
<input type="email" class="form-control" name="email">
</div>
<div class="mb-3">
<label class="form-label text-white">Password</label>
<input type="password" class="form-control" name="password">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
(생략)
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response){
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
(생략)
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
</div>
<script src="/js/article.js"></script>
(생략)
(생략)
datasource: # 데이터베이스 정보 추가
url: jdbc:h2:mem:testdb
username: sa
h2: # H2 콘솔 활성화
console:
enabled: true
이제 스프링 부트 서버를 실행하고 웹 브라우저에서 url을 입력하고 접속해 본다.
정말 로그인 화면이 구현되었다! 회원 가입도 해 본다. 그리고 H2 Console에 로그인한다.
콘솔창에 회원 테이블 전체 조회 sql문을 실행해 보면, 방금 회원 가입한 데이터가 성공적으로 insert 된 것을 볼 수 있다!
이렇게 스프링 시큐리티를 사용하여 로그인, 로그아웃, 회원 가입까지 모두 구현해 보았다. 복잡안 인증과 인가를 보다 편리하게 구현할 수 있다는 점에서 굉장한 기능인 것 같다..! 👍