1. 회원 가입 기능 구현하기
1) 회원 가입 기능 구성하기
- 회원 정보와 관련된 데이터를 저장하고 이를 관리하는 엔티티와 리포지터리
- 폼과 컨트롤러와 같은 요소를 생성해 사용자로부터 입력받은 데이터를 웹 프로그램에서 사용할 수 있도록
회원 엔티티 생성
속성 이름 | 설명 |
username | 사용자 이름(또는 사용자 ID) |
password | 비밀번호 |
이메일 |
user/SiteUser.java
package com.example.demo.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class SiteUser {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;
@Column(unique=true)
private String username;
private String password;
@Column(unique=true)
private String email;
}
- username, email 속성에는 @Column(unique=true)으로 지정하여 유일한 값만 저장할 수 있도록 함
User 리포지터리와 서비스 생성
UserRepository.java
package com.example.demo.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser,Long> {
}
UserService.java
package com.example.demo.user;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
- User 리포지터리를 사용하여 회원(User) 데이터를 생성하는 create 메서드 추가
- 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호 저장
BCryptPasswordEncoder 클래스
- 비크립트 해시 함수(BCrypt hashing function) 사용
- BCrypt는 해시 함수의 하나로 주로 비밀번호와 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술
- But, BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다는 PasswordEncoder 객체를 빈으로 등록해서 사용하는 것이 좋음
(암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아다니며 수정해야 하기 때문)
SecurityConfig.java
- @Configuration이 적용된 SecurityConfig.java 파일에 @Bean 메서드를 새로 추가하는 것
package com.example.demo;
...
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
UserSerivce.java
- 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용할 수 있도록 수정
package com.example.demo.user;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
회원 가입 폼 생성
UserCreateForm.java
- 회원가입을 위한 폼 클래스
package com.example.demo.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserCreateForm {
@Size(min=3, max=25)
@NotEmpty(message="사용자 ID는 필수 항목입니다.")
private String username;
@NotEmpty(message="비밀번호는 필수 항목입니다.")
private String password1;
@NotEmpty(message="비밀번호 확인은 필수 항목입니다.")
private String password2;
@NotEmpty(message="이메일은 필수 항목입니다.")
private String email;
}
- @Size는 문자열의 길이가 최소 길이(min)와 최대 길이(max) 사이에 해당하는지를 검증
- @Email은 해당 속성의 값이 이메일 형식과 일치하는지 검증
회원 가입 컨트롤러 생성
UserController.java
package com.example.demo.user;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup")
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if(bindingResult.hasErrors()) {
return "signup_form";
}
if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2()))
{
bindingResult.rejectValue("password2","passwordInCorrect","2개의 비밀번호가 일치하지 않습니다.");
return "signup_form";
}
userService.create(userCreateForm.getUsername(),userCreateForm.getEmail(), userCreateForm.getPassword1());
return "redirect:/";
}
}
- /user/signup URL이 GET으로 요청되면 회원 가입을 위한 템플릿을 렌더링하고, POST로 요청되면 회원 가입을 진행하도록 함
- 비밀번호 2개의 값이 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 오류가 발생하게 함
- bindingResult.rejectValue(필드명, 오류 코드, 오류 메시지)
- userService.create 메서드를 사용하여 사용자로부터 전달받은 데이터를 저장
2) 회원 가입 화면 구성하기
회원 가입 템플릿 생성
templates/signup_form.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<div class="my-3 border-bottom">
<div>
<h4>회원가입</h4>
</div>
</div>
<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" th:field="*{username}" class="form-control">
</div>
<div class="mb-3">
<label for="password1" class="form-label">비밀번호</label>
<input type="password" th:field="*{password1}" class="form-control">
</div>
<div class="mb-3">
<label for="password2" class="form-label">비밀번호 확인</label>
<input type="password" th:field="*{password2}" class="form-control">
</div>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" th:field="*{email}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">회원가입</button>
</form>
</div>
</html>
- [회원 가입] 버튼을 누르면 <form> 데이터가 POST 방식으로 /user/signup/URL에 전송됨
내비게이션 바에 회원 가입 링크 추가
templates/navbar.html
...
<li class="nav-link">
<a class="nav-link" th:href="@{/user/signup}">회원 가입</a>
</li>
...
3) 중복 회원 가입 방지
- 회원 가입 시 이미 동일한 ID와 이메일 주소가 있다는 것을 알리는 메시지가 나타나도록 수정
UserController.java
package com.example.demo.user;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup")
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if(bindingResult.hasErrors()) {
return "signup_form";
}
if(!userCreateForm.getPassword1().equals(userCreateForm.getPassword2()))
{
bindingResult.rejectValue("password2","passwordInCorrect","2개의 비밀번호가 일치하지 않습니다.");
return "signup_form";
}
try {
userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
}catch(DataIntegrityViolationException e) {
//중복된 데이터에 대한 예외 처리
e.printStackTrace();
bindingResult.reject("signupFaild", "이미 등록된 사용자입니다.");
return "signup_form";
}catch(Exception e) {
//DataIntegrityViolationException 외에 다른 예외 처리
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
- 사용자 ID 또는 이메일 주소가 이미 존재할 경우에는 DataIntegrityViolationException 예외가 발생하므로 '이미 등록된 사용자입니다.'라는 오류 메시지가 화면에 표시하도록 함
- 그 밖에 다른 예외들은 해당 예외에 관한 구체적인 오류 메시지를 출력하도록 e.getMessage()를 사용
2. 로그인과 로그아웃 기능 구현하기
1) 로그인 기능 구현
로그인 URL 등록
SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
.formLogin((formLogin)->formLogin
.loginPage("/user/login")
.defaultSuccessUrl("/"));
return http.build();
}
...
- formLogin 메서드는 스프링 시큐리티의 로그인 설정을 담당하는 부분
- 로그인 페이지의 URL은 /user/login이고 로그인 성공 시에 이동할 페이지는 루트 URL(/)임을 의미
User 컨트롤러에 URL 매핑 추가
UserController.java
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
...
@GetMapping("/login")
public String login() {
return "login_form";
}
}
- @GetMapping("/login")을 통해 /user/login URL로 들어오는 GET 요청을 login 메서드가 처리
- 매핑한 login 메서드는 login_form.html 템플릿을 출력하도록 함
- 실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리
로그인 템플릿 작성
templetes/login_form.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<form th:action="@{/user/login}" method="post">
<div th:if="${param.error}">
<div class="alert alert-danger">
사용자ID 또는 비밀번호를 확인해 주세요.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" name="username" id="username" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" name="password" id="password" class="form-control">
</div>
<button type="submit" class="btn btn-primary">로그인</button>
</form>
</div>
</html>
- 스프링 시큐리티의 로그인이 실패할 경우에는 시큐리티의 기능으로 인해 로그인 페이지로 리다이렉트됨
- 이때 페이지 매개변수로 error가 함께 전달됨
- 로그인 페이지의 매개변수로 error가 전달될 경우 '사용자 ID 또는 비밀번호를 확인해 주세요'라는 오류 메시지를 출력하도록 함
- 템플릿에서 ${param.error}로 error 매개변수가 전달되었는지 확인 가능
- 스프링 시큐리티에 무엇을 기준으로 로그인해야하는지 설정하지 않았기 때문에 로그인 수행 불가
<스프링 시큐리티를 통해 로그인을 수행하는 방법>
1) SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자 ID와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식
2) DB에서 회원 정보를 조회하여 로그인하는 방식
etc....
User 리포지터리 수정
- UserSecurityService는 사용자 ID를 조회하는 기능이 필요
- 사용자 ID로 SiteUser 엔티티를 조회하는 findByUsername 메서드를 User 리포지터리에 추가
UserRepository.java
package com.example.demo.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser,Long> {
Optional<SiteUser> findByUsername(String username);
}
UserRole 파일 생성
- 스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 내용이 필요
- 사용자가 로그인 한 후, ADMIN 또는 USER와 같은 권한을 부여해야 함
user/UserRole.java
package com.example.demo.user;
import lombok.Getter;
@Getter
public enum UserRole {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRole(String value){
this.value = value;
}
private String value;
}
- enum 자료형(열거 자료형)으로 작성
- 관리자를 의미하는 ADMIN과 사용자를 의미하는 USER라는 상수를 만듬
- ADMIN과 USER 상수는 값을 변경할 필요가 없으므로 @Setter 없이 @Getter만 사용할 수 있도록 함
UserSecurityService 서비스 생성
user/UserService.java
package com.example.demo.user;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService{
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<SiteUser>_siteUser = this.userRepository.findByUsername(username);
if(_siteUser.isEmpty()) {
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
}
SiteUser siteUser = _siteUser.get();
//사용자의 권한 정보를 나타내는 GrantedAuthority 객체를 생성하는 데 사용할 리스트 생성
List<GrantedAuthority>authorities = new ArrayList<>();
if("admin".equals(username)) {
authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
}else {
authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
}
return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
}
}
- 스프링 시큐리티가 로그인 시 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현(implements)해야 함
- UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스
- loadUserByUsername 메서드는 사용자명(username)으로 스프링 시큐리티의 사용자(User) 객체를 조회하여 리턴하는 메서드
- loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고, 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException을 발생
- 사용자명이 "admin"인 경우에는 ADMIN 권한을 부여하고 그 이외의 경우에는 USER 권한을 부여
- 마지막으로 User 객체를 생성해 반환하는 데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에는 사용자명, 비밀번호, 권한 리스트가 전달됨
스프링 시큐리티 설정 수정
SecurityConfig.java
package com.example.demo;
...
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
- AuthenticationManager 빈 생성
- AuthenticationManager는 스프링 시큐리티의 인증을 처리
- 사용자 인증 시 UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리
로그인 화면 수정
- 로그인 링크(/user/login)를 내비게이션 바에 추가
templates/navbar.html
- 로그인 상태에서는 로그아웃 표시 / 로그아웃 상태에서는 로그인 표시
-> 스프링 시큐리티의 타임리프 확장 기능을 사용하여 사용자의 로그인 상태 확인
sec:authorize="isAnonymous()" | 로그인되지 않은 경우 로그인 링크 표시 |
sec:authorize="isAuthenticated()" | 로그인된 경우에 로그아웃 링크 표시 |
templates/navbar.html
2) 로그아웃 기능 구현
- 로그아웃 기능 또한 스프링 시큐리티로 구현 가능
SecurityConfig.java
package com.example.demo;
...
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests)->authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
.csrf((csrf)->csrf
.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
.headers((headers)->headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
.formLogin((formLogin)->formLogin
.loginPage("/user/login")
.defaultSuccessUrl("/"))
.logout((logout)->logout
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true));
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
- 로그아웃 기능을 구현하기 위한 설정 추가
- 로그아웃 URL을 /user/logout으로 설정하고 로그아웃이 성공하면 루트(/) 페이지로 이동하도록 함
- .invalidateHttpSession(true)를 통해 로그아웃시 생성된 사용자 세션도 삭제하도록 처리
'SpringBoot' 카테고리의 다른 글
[Do it] 3장 스프링 부트 고급 기능 익히기(5) (0) | 2024.01.24 |
---|---|
[Do it] 3장 스프링 부트 고급 기능 익히기(4) (1) | 2024.01.24 |
[Do it] 3장 스프링 부트 고급 기능 익히기(2) (0) | 2024.01.23 |
[Do it] 3장 스프링 부트 고급 기능 익히기(1) (0) | 2024.01.22 |
[Do it] 스프링 부트 기본 기능 익히기(6) (1) | 2024.01.22 |