1. 스프링 시큐리티
스프링 시큐리티(spring security)
- 스프링 기반의 애플리케이션 보안(인증,인가,권한)을 담당하는 스프링 하위 프레임워크
- 스프링 기반 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크
- CSRF 공격, 세션 고정(session flxation) 공격을 방어해주고, 요청 헤더도 보안처리를 해주므로 개발자가 보안 관련 개발을 해야 하는 부담을 크게 줄여줌
인증과 인가
인증(authentication)
- 사용자의 신원을 입증하는 과정
인가(authorization)
- 사이트의 특정 부분에 접근할 수 있는지 권한을 확인하는 작업
필터 기반으로 동작하는 스프링 시큐리티
- 스프링 시큐리티는 다양한 필터들로 나누어져 있으며, 각 필터에서 인증,인가와 관련된 작업을 처리한다.
- SecurityContextPersistenceFilter부터 시작해서 아래로 내려가며 FilterSecurityInterceptor 까지 순서대로 필터를 거친다.
- 필터를 실행할 때는 회색 화살표로 연결된 오른쪽 박스의 클래스를 거치며 실행한다.
필터명 | 설명 |
보안 컨텍스트 지속성 필터 | SecurityContextRepository에서 SecurityContext(접근 주체와 인증에 대한 정보를 담고 있는 객체)를 가져오거나 저장하는 역할 |
로그아웃필터 | 설정된 로그아웃 URL로 오는 요청을 확인해 해당 사용자를 로그아웃 처리 |
사용자 이름 비밀번호 인증 필터 | 인증 관리자. 폼 기반 로그인을 할 때 사용되는 필터로 아이디, 패스워드 데이터를 파싱해 인증 요청을 위임. 인증 성공하면 AuthenticationSuccessHandler를, 인증에 실패하면 AuthenticationFailureHandler를 실행 |
기본로그인페이지생성필터 | 사용자가 로그인 페이지를 따로 지정하지 않았을 때 기본으로 설정하는 로그인 페이지 관련 필터 |
기본 인증 필터 | 요청 헤더에 있는 아이디와 패스워드를 파싱해서 인증 요청을 위임, 인증이 성공하면 AuthenticationSuccessHandler를, 인증에 실패하면 AuthenticationFailureHandler를 실행 |
요청캐시어웨어필터 | 로그인 성공 후 , 관련 있는 캐시 요청이 있는지 확인하고 캐시 요청을 처리. 예를 들어 로그인하지 않은 상태로 방문했던 페이지를 기억해두었다가 로그인 이후에 그 페이지로 이동시켜줌 |
보안 컨텍스트 홀더 인식 요청 필터 | HttpServeltRequest 정보를 감싼다. 필터 체인 상의 다음 필터들에게 부가 정보를 제공하기 위해 사용 |
익명 인증 필터 | 필터가 호출되는 시점까지 인증되지 않았다면 익명 사용자 전용 객체인 AnonymousAuthentication을 만들어 SecurityContext에 넣어줌 |
세션 관리 필터 | 인증된 사용자와 관련된 세션 관련 작업을 실행. 세션 변조 방지 전략을 설정하고, 유효 하지 않은 세션에 대한 처리를 하고, 세션 생성 전략을 세우는 등의 작업을 처리 |
예외 번역 필터 | 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달 |
필터보안인터셉터 | 접근 결정 관리자. AccessDecisionManager로 권한 부여 처리를 위임함으로써 접근 제어 결정을 쉽게 해준다. 이미 사용자가 인증되어 있으므로 유효한 사용자인지도 알 수 있다. 즉, 인가 관련 설정을 할 수 있다. |
1)
- 사용자가 폼에 아이디와 패스워드를 입력하면, HTTPServletRequest에 아이디와 비밀번호 정보가 전달된다.
이때 AuthenticationFilter가 넘어온 아이디와 비밀번호의 유효성 검사를 한다.
2)
- 유효성 검사가 끝나면 실제 구현체인 UsernamePasswordAuthenticationToken을 만들어 넘겨준다.
삼)
- 전달받은 인증용 객체인 UsernamePasswordAuthenticationToken을 AuthenticationManager에게 보낸다.
4)
- UsernamePasswordAuthenticationToken을 AuthenticationProvider에 보낸다.
5)
- 사용자 아이디를 UserDetailService에 보낸다. UserDetailService는 사용자 아이디로 찾은 사용자의 정보를 userDetails 객체로 만들어 AuthenticationProvider에게 전달한다.
6)
- DB에 있는 사용자 정보를 가져온다.
7)
- 입력 정보와 UserDetails의 정보를 비교해 실제 인증 처리를 한다.
8)~10) 까지 인증이 완료되면 SecurityContextHolder에 Authentication을 저장한다. 인증 성공 여부에 따라 성공하면 AuthenticationSuccessHandler, 실패하면 AuthenticationFailureHandler 핸들러를 실행한다.
2. 회원 도메인 만들기
- 스프링 시큐리티를 사용해 인증, 인가 기능을 구현
- 회원 정보를 저장할 테이블을 만들고 테이블과 연결할 도메인을 만든 다음, 이 테이블과 연결할 회원 엔티티를 만든다.
- 회원 엔티티와 연결되어 데이터를 조회하게 해줄 리포지터리를 만든 후, 마지막으로 스프링 시큐리티에서 사용자 정보를 가져오는 서비스 만들기
1) 의존성 추가하기
빌드.gradle
...
//스프링 시큐리티를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
//타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
//스프링 시큐리티를 테스트하기 위한 의존성 추가
testImplementation 'org.springframework.security:spring-security-test'
...
2) 엔티티 만들기
회원 엔티티
컬럼명 | 자료형 | null 허용 | 키 | 설명 |
id | BIGINT | N | 기본키 | 일련번호, 기본키 |
VARCHAR(255) | N | 이메일 | ||
password | VARCHAR(255) | N | 패스워드(암호화하여 저장) | |
created_at | DATETIME | N | 생성일자 | |
updated_at | DATETIME | N | 수정일자 |
- domain 패키지에 User.java 파일을 생성하고 UserDetails 클래스를 상속하는 User 클래스 만들기
User.java
@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"));
}
//사용자의 id를 반환(고유한 값)
@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->사용 가능
}
}
- User 클래스가 상속한 UserDetails 클래스는 스프링 시큐리티에서 사용자의 인증 정보를 담아 두는 인터페이스이다.
- 스프링 시큐리티에서 해당 객체를 통해 인증 정보를 가져오려는 필수 오버라이드 메서드
메서드 | 반환 타입 | 설명 |
getAuthorities() | 컬렉션<? extends GrantedAuthority> | 사용자가 가지고 있는 권한의 목록을 반환한다. 현재 예제 코드에서는 사용자 이외의 권한이 없기 때문에 user 권한만 담아 반환 |
getUsername() | 끈 | 사용자를 식별할 수 있는 사용자 이름을 반환한다. 이때 사용되는 사용자 이름은 반드시 고유해야 한다. 현재 예제 코드는 유니크 속성이 적용된 이메일을 반환 |
getPassword() | 끈 | 사용자의 비밀번호를 반환한다. 이때 저장되어 있는 비밀번호는 암호화해서 저장 |
계정이 만료되지 않음() | 부울 | 계정이 만료되었는지 확인하는 메서드, 만약 만료되지 않은 때는 true를 반환 |
계정 잠금 해제() | 부울 | 계정이 잠금되었는지 확인하는 메서드, 만약 잠금되지 않은 때는 true를 반환 |
자격 증명 만료 안 됨() | 부울 | 비밀번호가 만료되었는지 확인하는 메서드, 만약 만료되지 않은 때는 true를 반환 |
사용 가능() | 부울 | 계정이 사용 가능한지 확인하는 메서드, 만약 사용가능하다면 true를 반환 |
리포지터리 만들기
- User 엔티티에 대한 리포지터리 만들기
사용자 저장소.java
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByEmail(String email); //email로 사용자 정보를 가져옴
}
- 이메일로 사용자를 식별할 수 있다. 따라서 사용자 정보를 가져오기 위해서는 스프링 시큐리티가 이메일을 전달받아야 한다. 스프링 데이터 JPA는 메서드 규칙에 맞춰 메서드를 선언하면 이름을 분석해 자동으로 쿼리를 생성해준다.
- findByEmail() 메서드는 실제 데이터베이스에 회원 정보를 요청할 때 다음 쿼리를 실행한다.
사용자로부터
이메일 = #{이메일} 인 경우
서비스 메서드 코드 작성하기
- 스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 코드를 작성
사용자 세부 서비스.java
- 스프링 시큐리티에서 사용자의 정보를 가져오는 UserDetailsService 인터페이스를 구현한다.
@RequiredArgsConstructor
@Service
public class UserDetailService는 UserDetailsService를 구현합니다 .
개인 최종 사용자 저장소 사용자 저장소;
@Override
public User loadUserByUsername (문자열 이메일) {
return userRepository.findByEmail(이메일)
.orElseThrow(() -> 새로운 IllegalArgumentException((이메일)));
}
}
3. 시큐리티 설정하기
config/WebSecurityConfig.java
- 실제 인증 처리를 하는 시큐리티 설정 파일
@RequiredArgsConstructor
@Configuration
공개 클래스 WebSecurityConfig {
개인 최종 UserDetailService userService;
//1. 스프링 시큐리티 기능 비활성화
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring()
.requestMatchers(toH2Console())
.requestMatchers( 새로운 AntPathRequestMatcher( "/static/**" ));
}
//2.특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeRequests(auth -> auth //3.인증,인가 설정
.요청매처(
새로운 AntPathRequestMatcher( "/login" ),
새로운 AntPathRequestMatcher( "/signup" ),
새로운 AntPathRequestMatcher( "/user" )
).모두 허용()
.모든요청().인증됨())
.formLogin(formLogin -> formLogin //4. 폼 기반 로그인 설정
.loginPage("/login")
.defaultSuccessUrl( "/기사" )
).logout(logout -> logout //5.로그아웃 설정
.logoutSuccessUrl("/login")
.invalidateHttpSession( 참 )
)
.csrf(AbstractHttpConfigurer::disable) //6.csrf 비활성화
.짓다();
}
//7. 인증 관리자 관련 설정
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http,
BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService)
가 예외를 throw합니다 .
Dao 인증 제공자 인증 제공자 = 새로운 Dao 인증 제공자();
authProvider.setUserDetailsService(userService); //8. 사용자 정보 서비스 설정
authProvider.setPasswordEncoder(bCryptPasswordEncoder);
새로운 ProviderManager(authProvider)를 반환합니다 .
}
//8. 패스워드 인코더로 사용할 빈 등록
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
1)
- 스프링 시큐리티의 모든 기능을 사용하지 않게 설정하는 코드
- 즉, 인증, 인가 서비스를 모든 곳에 적용하지는 않는다.
- 일반적으로 정적 리소스(이미지,HTML 파일)에 설정한다.
- 정적 리소스만 스프링 시큐리티 사용을 비활성화하는 데 static 하위 경로에 있는 리소스와 h2의 데이터를 확인하는데 사용하는 h2-console 하위 url을 대상으로 ignoring() 메서드를 사용한다.
2)
- 특정 HTTP 요청에 대한 웹 기반 보안을 구성
- 이 메서드에서 인증/인가 및 로그인, 로그아웃 관련 설정할 수 있다.
삼)
- 특정 경로에 대한 엑세스 설정
- requestMatchers() : 특정 요청과 일치하는 url에 대한 엑세스를 설정
- permitAll() : 누구나 접근이 가능하게 설정
- anyRequest() : 위에서 설정한 url 이외의 요청에 대해서 설정
- authenticated() : 별도의 인가는 필요하지 않지만 성공된 상태여야 접근할 수 있다.
4)
- 폼 기반 로그인 설정
- loginPage() : 로그인 페이지 경로를 설정
- defaultSuccessUrl() : 로그인이 완료되었을 때 이동할 경로를 설정
5)
- 로그아웃 설정
- logoutSuccessUrl() : 로그아웃이 완료되었을 때 이동할 경로를 설정
- invalidateHttpSession() : 로그아웃 이후에 세션을 전체 삭제할지 여부를 설정
6)
- CSRF 설정을 비활성화
7)
- 인증 관리자 관련 설정
- 사용자 정보를 가져올 서비스를 재정의하거나, 인증 방법, 예를 들어 LDAP, JDBC 기반 인증 등을 설정할 때 사용
8)
- 사용자 서비스를 설정
- userDetailsService() : 사용자 정보를 가져올 서비스를 설정. 이때 설정하는 서비스 클래스는 반드시 UserDetailsService를 상속받은 클래스여야 한다.
- passwordEncoder() : 비밀번호를 암호화하기 위한 인코더를 설정
4. 회원 가입 구현하기
1) 서비스 메서드 코드 작성
dto/AddUserRequest.java
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
service/UserService.java
- AddUserRequest 객체를 인수로 받는 회원 정보 추가 메서드를 작성
@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();
}
}
2) 컨트롤러 작성
- 회원 가입 폼에서 회원 가입 요청을 받으면 서비스 메서드를 사용해 사용자를 저장한 뒤, 로그인 페이지로 이동하는 signup() 메서드를 작성
UserApiController.java
- 회원 가입 처리가 된 다음 로그인 페이지로 이동하기 위해 redirect: 접두사를 붙였다. 이렇게 하면 회원 가입 처리가 끝나면 강제로 /login URL에 해당하는 화면으로 이동
@RequiredArgsConstructor
@Controller
public class UserApiController {
private final UserService userService;
@PostMapping("/user")
public String signup(AddUserRequest request) {
userService.save(request); //회원 가입 메서드 호출
return "redirect:/login"; //회원 가입이 완료된 이후에 로그인 페이지로 이동
}
}
5. 회원 가입, 로그인 뷰 작성하기
1) 뷰 컨트롤러 구현
UserViewController.java
- /login 경로로 접근하면 login() 메서드가 login.html을, /signup 경로에 접근하면 signup() 메서드는 signup.html를 반환
@Controller
public class UserViewController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/signup")
public String signup() {
return "signup";
}
}
2) 뷰 작성
templates/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>
templates/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>
6. 로그아웃 구현하기
1) 로그아웃 메서드 추가
UserApiController.java
- logout() 메서드를 추가
- logout GET 요청을 하면 로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandler의 logout() 메서드를 호출해서 로그아웃
...
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
2) 로그아웃 뷰 추가
articleList.html
- [로그아웃] 버튼을 추가
...
<button type="button" class="btn btn-secondary" onclick="location.href='/logout'">로그아웃</button>
...
7. 실행 테스트하기
1) 테스트를 위한 환경 변수 추가
application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
datasource: # 1.데이터베이스 경로 추가
url: jdbc:h2:mem:testdb
username: sa
h2: # 2. H2 콘솔 활성화
console:
enabled: true
'SpringBoot' 카테고리의 다른 글
[스프링 부트 3 백엔드 개발자 되기] ch 10. OAuth2로 로그인/로그아웃 구현하기 (0) | 2024.07.25 |
---|---|
[스프링 부트 3 백엔드 개발자 되기] ch 9. JWT로 로그인/로그아웃 구현하기 (1) | 2024.07.24 |
[스프링 부트 3 백엔드 개발자 되기] ch 7. 블로그 화면 구성하기 (0) | 2024.07.17 |
[스프링 부트 3 백엔드 개발자 되기] ch 6. 블로그 기획하고 API 만들기 (0) | 2024.07.17 |
[스프링 부트 3 백엔드 개발자 되기] ch 5. 데이터베이스 조작이 편해지는 ORM (0) | 2024.07.12 |