본문 바로가기

SpringBoot

[Do it] 3장 스프링 부트 고급 기능 익히기(3)

1. 회원 가입 기능 구현하기 

 

1) 회원 가입 기능 구성하기 

 

- 회원 정보와 관련된 데이터를 저장하고 이를 관리하는 엔티티와 리포지터리 

- 폼과 컨트롤러와 같은 요소를 생성해 사용자로부터 입력받은 데이터를 웹 프로그램에서 사용할 수 있도록 

 

회원 엔티티 생성

 

속성 이름 설명
username 사용자 이름(또는 사용자 ID)
password 비밀번호
email 이메일 

 

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)를 통해 로그아웃시 생성된 사용자 세션도 삭제하도록 처리