8.1 사전 지식 : 스프링 시큐리티
- 인증 : 사용자의 신원을 입증하는 과정
- 인가 : 사이트의 특정 부분에 접근할 수 있는지에 대한 권한을 확인하는 작업 (관리자와 사용자 페이지)
- 스프링 시큐리티 : 스프링 기반의 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크(필터 기반 동작)
<스프링 시큐리티의 다양한 필터중 눈 여겨볼 필터>
- UsernamePasswordAuthenticationFilter : 아이디와 패스워드가 넘어오면 인증 요청을 위임하는 인증 관리자 역할
- FilterSecurityInterceptor : 권한 부여 처리를 위임해 접근 제어 결정을 쉽게 하는 접근 결정 관리자 역할
그 이외의 필터들은 직접 사용하면서 공부하기...
8.2 회원 도메인 만들기
8.2.1 의존성 추가하기
1단계. build.gradle 수정
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.2' // 스프링 부트 플러그인
id 'io.spring.dependency-management' version '1.1.0' // 스프링 의존성 자동 관리 플러그인
}
group 'me.kyungsoolee' // 그룹 이름
version '1.0' // 버전
sourceCompatibility = '17' // 자바 버전
repositories { // 의존성을 받을 저장소
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' // 웹 관련 기능 제공
// 스프링 데이터 JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스
compileOnly 'org.projectlombok:lombok' // 롬복
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test' // 테스트 기능 제공
// 스프링 시큐리티를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
// 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// 스프링 시큐리티를 테스트하기 위한 의존성 추가
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
8.2.2 엔티티 만들기
1단계. User 클래스 생성
package me.kyungsoolee.springbootdeveloperblog.domain;
import jakarta.persistence.*;
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
@Getter
@Entity
public class User implements UserDetails { // UserDetails를 상속받아 인증 객체로 사용
@Id
@GeneratedValue
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password")
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 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 -> 사용 가능
}
}
메서드 | 반환 타입 | 설명 |
getAuthorities() | Collection<? extends GrantedAuthority> | 사용자가 가지고 있는 권한의 목록을 반환합니다. 현재 예제 코드에서는 사용자 이외의 권한이 없기 때문에 user 권한만 담아 반환합니다. |
getUsername() | String | 사용자를 식별할 수 있는 사용자 일므을 반환합니다. 이때 사용되는 사용자 이름은 반드시 고유해야 합니다. 현재 예제 코드는 유니크 속성이 적용된 이메일을 반환합니다. |
getPassword() | String | 사용자의 비밀번호를 반환합니다. 이때 저장되어 있는 비밀번호는 암호화해서 저장해야 합니다. |
isAccountNonExpired() | boolean | 계정이 만료되었는지 확인하는 메서드입니다. 만약 만료되지 않은 때는 true를 반환합니다. |
isAccountNonLocked() | boolean | 계정이 잠금되었는지 확인하는 메서드입니다. 만약 잠금되지 않은 때는 true를 반환합니다. |
isCredentialsNonExpired() | boolean | 비밀번호가 만료되었는지 확인하는 메서드입니다. 만약 만료되지 않은 때는 true를 반환합니다. |
isEnabled() | boolean | 계정이 사용 가능한지 확인하는 메서드입니다. 만약 사용 가능하다면 true를 반환합니다. |
8.2.3 리포지터리 만들기
1단계. UserRepository 구현
package me.kyungsoolee.springbootdeveloperblog.repository;
import me.kyungsoolee.springbootdeveloperblog.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); // email로 사용자 정보를 가져옴
}
- findByEmail() 메서드는 실제 데이터베이스에 회원 정보를 요청할 때 아래와 같은 쿼리 실행
FROM users
WHERE email = #{email}
<자주 사용하는 쿼리 메서드의 명명규칙>
코드 | 설명 | 쿼리 |
findByName() | "name" 컬럼의 값 중 파라미터로 들어오는 값과 같은 데이터 반환 | ... WHERE name = ?1 |
findByNameAndAge() | 파라미터로 들어오는 값 중 첫 번째 값은 "name"컬럼에서 조회하고, 두 번째 값은 "age" 컬럼에서 조회한 데이터 반환 | ... WHERE name = ?1 AND age = ?2 |
findByNameOrAge() | 파라미터로 들어오는 값 중 첫 번째 값이 "name" 컬럼에서 조회되거나 두 번째 값이 "age"에서 조회되는 데이터 반환 | ... WHERE name = ?1 OR age = ?2 |
findbyAgeLessThan() | "age" 컬럼의 값 중 파라미터로 들어온 값보다 작은 데이터 반환 | ... WHERE age < ?1 |
findByAgeGraterThan() | "age" 컬럼의 값 중 파라미터로 들어온 값보다 큰 데이터 반환 | ... WHERE age > ?1 |
findByName(Is)Null() | "name"컬럼의 값 중 null인 데이터 반환 | ... WHERE name IS NULL |
8.2.4 서비스 메서드 코드 작성하기
1단계. 스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 메서드 작성 (UserDetailService.java)
package me.kyungsoolee.springbootdeveloperblog.service;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.domain.User;
import me.kyungsoolee.springbootdeveloperblog.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));
}
}
8.3 시큐리티 설정하기
1단계. WebSecurityConfig.java 구현 : 실제 인증 처리를 하는 시큐리티 설정 파일
1. 스프링 시큐리티의 모든 기능 비활성화
2. 특정 HTTP 요청에 대한 웹 기반 보안 구성
3. 특정 경로에 대한 액세스 설정
4. 폼 기반 로그인 설정
5. 로그아웃 설정
6. CSRF 비활성화 (공격 방지를 위해서는 활성화가 좋지만, 실습을 위해 비활성화)
7. 인증 관리자 관련 설정 : 사용자 정보 불러오기 서비스 재정의, 인증방법 설정할 때 활용
8. 사용자 서비스 설정
9. 패스워드 인코더를 빈으로 등록
package me.kyungsoolee.springbootdeveloperblog.config;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.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();
}
}
8.4 회원 가입 구현하기
8.4.1 서비스 메서드 구현
1단계. AdduserRequest.java 구현
package me.kyungsoolee.springbootdeveloperblog.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
2단계. UserService.java 구현
package me.kyungsoolee.springbootdeveloperblog.service;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.domain.User;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.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();
}
}
8.4.2 컨트롤러 작성
1단계. UserApiController.java 작성
package me.kyungsoolee.springbootdeveloperblog.controller;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.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 "redirection:/login";
}
}
8.5 회원 가입, 로그인 뷰 작성하기
8.5.1 뷰 컨트롤러 구현하기
1단계. UserViewContoller 구현
package me.kyungsoolee.springbootdeveloperblog.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";
}
}
8.5.2 뷰 작성하기
1단계. login.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(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>
2단계. signup.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>
8.6.1 로그아웃 메서드 추가하기
1단계. UserApiController에 logout 기능 추가
package me.kyungsoolee.springbootdeveloperblog.controller;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.kyungsoolee.springbootdeveloperblog.dto.AddUserRequest;
import me.kyungsoolee.springbootdeveloperblog.service.UserService;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
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";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
}
2단계. articleList.html 구현
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container">
<button type="button" id="create-btn"
th:onclick="|location.href='@{/new-article}'|"
class="btn btn-secondary btn-sm mb-3">글 등록</button>
<div class="row-6" th:each="item : ${articles}">
<div class="card">
<div class="card-header" th:text="${item.id}">
</div>
<div class="card-body">
<h5 class="card-title" th:text="${item.title}"></h5>
<p class="card-text" th:text="${item.content}"></p>
<a th:href="@{/articles/{id}(id=${item.id})}" class="btn btn-primary">보러가기</a>
</div>
</div>
<br>
</div>
<button type="button" class="btn btn-secondary" onclick="location.href='logout'">로그아웃</button>
</div>
<script src="/js/article.js"></script>
</body>
8.7 실행 테스트
'Spring boot > 스프링 부트 3 백엔드 개발자 되기(자바편)' 카테고리의 다른 글
[6장] 블로그 기획하고 API 만들기(2) (0) | 2023.07.25 |
---|---|
[5장] 데이터베이스 조작이 편해지는 ORM (0) | 2023.07.25 |
[4장] 스프링 부트 3와 테스트 (0) | 2023.07.25 |
[3장] 스프링 부트3 구조 이해하기 (0) | 2023.07.25 |
[2장] 스프링 부트 3 시작하기(2) - 스프링 부트 3 예제 만들기 (0) | 2023.07.25 |