<스프링 시큐리티 기본 개념 이전글 참고>
<블로그 만들기 참고 (코드 이전과 이어짐) >
https://jhzlo.tistory.com/30
1. 회원 도메인 만들기
다음과 같은 순서로 스프링 시큐리티를 사용해 인증, 인가 기능을 구현할 것이다.
- 회원 정보를 저장할 테이블 구성
↓ - 테이블과 연결할 도메인을 만듦
↓ - 테이블과 연결할 회원 엔티티 구성
↓ - 회원 엔티티와 연결되어 데이터를 조회하게 해 줄 리포지터리 구성
↓ - 스프링 시큐리티에서 사용자 정보를 가져옴
1-1. 의존성 추가
build.gradle
dependencies {
--- 생략 ---
// 스프링 시큐리티를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
// 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성 추가
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// 스프링 시큐리티를 테스트하기 위한 의존성 추가
implementation 'org.springframework.security:spring-security-test'
}
1-2. 엔티티
회원 엔티티와 매핑할 테이블의 구조는 다음과 같다.
컬럼명 | 자료형 | null 허용 | 키 | 설명 |
id | BIGINT | N | 기본키 | 일렬번호, 기본키 |
VARCHAR(255) | N | 이메일 | ||
password | VARCHAR(255) | N | 패스워드(암호화 하여 저장 | |
created_at | DATETIME | N | 생성일자 | |
updated_at | DATETIME | N | 수정일자 |
domain/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")
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;
}
}
User 클래스가 상속한 UserDetails 클래스 : 스프링 시큐리티에서 사용자의 인증 정보를 담아두는 인터페이스
스프링 시큐리티에서 해당 객체를 통해 인증 정보를 가져오려면 필수 오버라이드 메서드들을 여러 개 사용해야 한다.
메서드 | 반환 타입 | 설명 |
getAuthorities() | Collection<? extends GrantedAuthority> | 사용자가 가지고 있는 권한의 목록을 반환한다. 현재 예제 코드에서는 사용자 이외의 권한이 없기 때문에 user 권한만 담아 반환한다. |
getUsername() | String | 사용자를 식별할 수 있는 사용자의 이름을 반환. 이때 사용되는 사용자 이름은 반드시 고유해야 한다. 현재 예제 코드는 유니크 속성이 적용된 이메일을 반환한다. |
getPassword() | String | 사용자의 비밀번호를 반환한다. 이때 저장되어 있는 비밀번호는 암호화해서 저장해야한다. |
isAccountNonExpired() | boolean | 계정이 만료되었는지 확인하는 메서드다. 만약 만료되지 않은 때는 true를 반환한다. |
isAccountNonLocked() | boolean | 계정이 잠금되었는지 확인하는 메서드다. 만약 잠금되지 않은 때는 true를 반환한다. |
isCredentialsNonExpired() | boolean | 비밀번호가 만료되었는지 확인하는 메서드다. 만약 만료되지 않은 떄는 true를 반환한다. |
isEnabled() | boolean | 계정이 사용 가능한지 확인하는 메서드다. 사용가능하다면 true를 반환한다. |
1-3. 리포지터리
repository/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // email로 사용자 정보를 가져옴
}
이메일로 사용자를 식별할 수 있다.
따라서 사용자 정보를 가져오기 위해서는 시프링 시큐리티가 이메일을 전달받아야 한다.
스프링 데이터 JPA는 메서드 규칙에 맞춰 메서드를 선언하면 이름을 분석해 자동으로 쿼리를 생성한다.
findByEmail()메서드는 실제 데이터 베이스에 회원 정보를 요청할 때 다음 쿼리를 실행한다.
◆ JPA 자주 사용하는 쿼리 메서드 명명 규칙
코드 | 설명 | 쿼리 |
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 |
findByAgeGreaterThan() | "age" 컬럼의 값 중 파라미터로 들어온 값보다 큰 데이터 반환 | ... WHERE age > ?1 |
findByName(Is)Null() | "name" 컬럼의 값 중 null인 데이터 반환 | ... WHERE name IS NULL |
1-4. 서비스 메서드
service/UserDetailService.java
스프링 시큐리티에서 로그인을 진행할 때 사용자 정보를 가져오는 코드 작성
@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)));
}
}
스프링 시큐리티에서 사용자의 정보를 가져오는 UserDetailsService 인터페이스 구현
필수로 구현해야 하는 loadUserByUsername() 메서드를 오버라이딩해서 사용자 정보를 가져오는 로직을 작성
2. 시큐리티 설정
실제 인증 처리를 하는 시큐리티 설정 파일 WebSecurityConfig.java를 작성
config 패키지 새로 생성
config/WebSecurityConfig.java
@RequiredArgsConstructor
@Configuration
public class webSecurityConfig {
private final UserDetailService userService;
// 1. 스프링 시큐리티 기능 비활성화
@Bean
public WebSecurityCustomizer configure(){
return (web) -> web.ignoring()
.requestMatchers(toH2Console())
.requestMatchers("/static/**");
}
// 2. 특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
.authorizeRequests() // 3. 인증, 인가 설정
.requestMatchers("/login", "/signup", "/user").permitAll()
.anyRequest().authenticated()
.and()
.formLogin() // 4. 폼 기반 로그인 설정
.loginPage("/login")
.defaultSuccessUrl("/articles")
.and()
.logout() // 5. 로그아웃 설정
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.and()
.csrf().disable() // 6. csrf 비활성화
.build();
}
// 7. 인증 관리자 관련 설정
@Bean
public AuthenticationManager authenticationManager
(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailService userDetailService)
throws Exception{
return http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userService) // 8. 사용자 정보 서비스 설정
.passwordEncoder(bCryptPasswordEncoder)
.and()
.build();
}
// 9. 패스워드 인코더로 사용할 빈 등록
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
- 스프링 시큐리티의 모든 기능을 사용하지 않게 설정하는 코드
즉, 인증, 인가 서비스를 모든 곳에 적용하지는 않는다.
일반적으로 정적 리소스(html, 이미지)만 스프링 시큐리티 사용을 비활성화한다.
static 하위 경로에 있는 리소스와
h2의 데이터를 확인하는 데 사용하는 h2-console 하위 url을 대상으로 ignoring() 메서드 사용 - 특정 HTTP 요청에 대해 웹 기반 보안을 구성한다.
이 메서드에서 인증/인가 및 로그인, 로그아웃 관련 설정할 수 있다. - 특정 경로에 대한 액세스 설정을 한다.
- requestMatcher() : 특정 요청과 일치하는 url에 대한 액세스를 설정한다.
- permitAll() : 누구나 접근이 가능하게 설정한다.
즉 "/login", "/signup", "/user"로 요청이 오면 인증/인가 없이도 접근 가능함
- anyRequest(): 위에서 설정한 url 이외의 요청에 대해서 설정
- authenticated() : 별도의 인가는 필요하지 않지만 인증이 성공된 상태여야 접근할 수 있다. - 폼 기반 로그인 설정
- loginPage() : 로그인 페이지 경로를 설정한다.
- defaultSuccessUrl() : 로그인이 완료되었을 때 이동할 경로를 설정한다. - 로그아웃 설정
- logoutSuccessUrl() : 로그아웃이 완료되었을 때 이동할 경로 설정
- invalidateHttpSession() : 로그아웃 이후에 세션을 전체 삭제할지 여부 설정 - CSRF 설정 비활성화
- 인증 관리자 관련 설정
사용자 정보를 가져올 서비스를 재정의하거나, 인증 방법,
예를 들어 LDAP, JDBC 기반 인증 등을 설정할 때 사용한다. - 사용자 서비스 설정
- userDetailsService() : 사용자 정보를 가져올 서비스를 설정한다.
이때 설정하는 서비스 클래스는 반드시 UserDetailsSevice를 상속받은 클래스여야 한다.
- passwordEncoder() : 비밀번호를 암호화하기 위한 인코더를 설정한다. - 패스워드 인코더를 빈으로 등록한다.
3. 회원가입 구현하기
3-1. 서비스 메서드
dto/AddUserRequest.java
사용자 정보를 담고 있는 객체 생성
@Getter
@Setter
public class AddUserRequest {
private String email;
private String password;
}
유저 요청
즉, 로그인 정보에 필요한 email, password에 대한 객체 생성
service/UserService.java
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private Long save(AddUserRequest dto){
return userRepository.save(User.builder()
.email(dto.getEmail())
// 패스워드 암호화
.password(bCryptPasswordEncoder.encode(dto.getPassword()))
.build()).getId();
}
}
AddUserRequest 객체를 인수로 받는 회원 정보 추가 메서드를 작성
패스워드를 저장할 때 시큐리티를 설정하여 패스워드 인코딩용으로 등록한 빈을 사용해서 암호화한 후 저장한다.
3-2. 컨트롤러
controller/UserApiController.java
@RequiredArgsConstructor
@Controller
public class UserApiController {
private final UserService userService;
@PostMapping("/user")
public String signup(AddUserRequest request){
userService.save(request); // 회원 가입 메서드 호출
return "redirect:/login"; // 회원 가입이 완료된 이후에 로그인 페이지로 이동
}
}
- 회원 가입 폼에서 회원 가입 요청을 받으면 서비스 메서드를 사용해 사용자를 저장
- 로그인 페이지로 이동하는 signup() 메서드 작성
- 회원 가입 처리가 끝나면 강제로 /login URL에 해당하는 화면으로 이동
4. 회원가입, 로그인 뷰 작성하기
- 회원 가입, 로그인 경로에 접근하면 회원 가입, 로그인 화면으로 연결해 주는 컨트롤러 생성
- 사용자가 실제로 볼 수 있는 화면 작성
4-1. 뷰 컨트롤러 구현
controller/UserViewController.java
@Controller
public class UserViewController {
@GetMapping("/login")
public String login(){
return "login";
}
@GetMapping("/signup")
public String signup(){
return "signup";
}
}
4-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>
5. 로그아웃 구현하기
5-1. 메서드 추가
controller/UserApiController.java
@RequiredArgsConstructor
@Controller
public class UserApiController {
--- 생략 ---
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response){
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
}
/logout GET 요청을 하면
로그아웃을 담당하는 핸들러인 SecurityContextLogoutHandler의 logout() 메서드를 호출해서 로그아웃한다.
5-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>
기존의 aritleList.html에 로그아웃 버튼을 추가해 준다.
6. 실행 테스트
6-1. 환경 변수 추가
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
datasource:
url: jdbc:h2:mem:testdb
username: sa
h2:
console:
enabled: true
- 데이터베이스 정보 추가
H2 데이터베이스 사용 - h2 콘솔 활성화
실제로 데이터베이스에 추가되는 데이터를 확인하기 위해 콘솔을 활성화하는 옵션
6-2. 로그인, 회원 가입 테스트
1. 로그인
http://localhost:8080/login
http://localhost:8080/articles에 접근을 시도하려고 하면
/articles는 인증된 사용자만 들어갈 수 있는 페이지이므로 로그인 페이지인 /login으로 리다이렉트 된다.
2. 회원가입
http://localhost:8080/signup
3. 회원가입 후 로그인
회원가입 후 로그인을 진행하면 다음과 같이 인증이 되면서
/articles 페이지에 접근할 수 있게 된다.
4. 데이터베이스 확인하기
http://localhost:8080/h2-console
h2-console로 접속하여 위와 같이 데이터를 입력한 후(password는 빈칸) connect를 누른다.
위와 같이 users 테이블에 접근하는 sql문을 작성하고 실행하면
아까 회원가입 때 입력했던 데이터들이 users 테이블에 들어간 것을 확인할 수 있다.
이때, password의 데이터는 암호화가 되었음도 확인할 수 있다.
6-3. 로그아웃 테스트
http://localhost:8080/logout
/logout으로 이동하거나
위의 사진처럼 로그아웃 버튼을 누르게 되면
인증정보가 없어짐과 동시에 로그인 페이지로 리다이렉트 됨을 확인할 수 있다.
출처:https://github.com/shinsunyoung/springboot-developer
'JAVA > SpringBoot 3' 카테고리의 다른 글
[스프링 부트 3] JWT - 2 (JWT 서비스, 토큰 생성자 구현, 로그인/로그아웃) (0) | 2024.08.22 |
---|---|
[스프링 부트 3] JWT - 1 (토큰 기반 인증, JWT, 액세스 토큰, 리프레시 토큰) (0) | 2024.08.18 |
[스프링 부트 3] 스프링 시큐리티 - 1 (스프링 시큐리티, 인증과 인가 ) (0) | 2024.08.14 |
[스프링 부트 3] 블로그 만들기 - 3 (타임리프를 활용하여 블로그 뷰 구현하기) (0) | 2024.08.12 |
[스프링 부트 3] 템플릿 엔진, 타임리프란? (0) | 2024.08.12 |