PROJECT/[SpringBoot] 게시판 서비스

[Spring Boot - 게시판 서비스] #1. 로그인 / 회원가입 구현

MoveForward 2024. 10. 13. 03:02

'Spring Security' 를 이용하여 회원 로그인 / 회원가입 기능을 구현하고자 한다.

 

- 0. 프로젝트 트리

* config 디렉토리

스프링 시큐리티를 통한 회원 로그인을 구현하기 위한 설정 파일이 포함된다.

 

* controller 디렉토리

 

* dto 디렉토리

 

* entity 디렉토리

 

* exception 디렉토리

발생할 각종 예외를 처리하기 위한 exception 파일이 포함된다.

 

* repository 디렉토리

 

* service 디렉토리

 


- 1. Entity 구성

+) MemberDto 구성

public class MemberDto {

    @Data
    public static class Request {
        private Long id;
        private String username;
        private String password;
        private String nickname;
        private String email;
        private String phone;
        private MemberRole role;

        private String password2;

        //DTO -> Entity
        public Member toEntity() {
            return new Member(
                    this.id,
                    this.username,
                    this.password,
                    this.nickname,
                    this.email,
                    this.phone,
                    this.role
            );
        }
    }

    @Data
    public static class Response {
        private Long id;
        private String username;
        private String password;
        private String nickname;
        private String email;
        private String phone;
        private MemberRole role;

        //Entity -> DTO
        public Response(Member member) {
            this.id = member.getId();
            this.username = member.getUsername();
            this.password = member.getPassword();
            this.nickname = member.getNickname();
            this.email = member.getEmail();
            this.phone = member.getPhone();
            this.role = member.getRole();
        }
    }
}

 


- 2. Repository 구성

package project.board_service.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import project.board_service.entity.Member;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByUsername(String username);
}

Spring Data Jpa 를 이용하여 Repository 를 구성한다.


- !. Spring Security 설정 파일 - (config 디렉토리)

이 부분이 DB의 Member 테이블에 저장된 '회원 데이터'로 로그인하기 위해 가장 중요하다!

1. CustomUserDetailsService.java

 : 이 파일의 목적은 회원 데이터로 로그인 할것이라는 것을 Spring Security 에게 알려주기 위함이다.

 

"loadUserByUsername" 메서드의 return 타입을 보면,

Member 객체의 username, password, 권한 을 이용하여 "User" (Spring Security의 로그인 타입) 을 생성하여 반환하는 것을 볼 수 있다.

즉, 회원 데이터 -> "User" (Spring Security의 로그인 타입) 으로 변환하는 과정이다.

 

"getAuthorities" 메서드는 Member 객체의 권한명 앞에 "ROLE_" 을 붙여서, Spring Security 의 권한 타입으로 변환한다.

CF) "ROLE_권한명" 은 Spring Security 에서 권한을 표현하는 일반적인 표현법이다.

 

[CustomUserDetailsService.java]

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired private MemberRepository memberRepository;

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with name: " + username));

        return new User(member.getUsername(), member.getPassword(), getAuthorities(member));
    }

    private Collection<? extends GrantedAuthority> getAuthorities(Member member) {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()));
        return authorities;
    }
}

 

2. WebSecurityConfig.java

 : 이 파일의 목적은 웹 서비스 접근에 관한 설정을 하는 것이다.

 

[WebSecurityConfig.java]

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    /*PasswordEncoder Bean 등록 - password 암호화 (방식 - BCryptPasswordEncoder)*/
    @Bean
    public static PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}

    /*WebSecurityCustomizer Bean 등록 - 정적 resources 접근을 위함*/
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring()
                .requestMatchers("/img/**", "/css/**")
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()));
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers("/", "/members/login", "/members/join").permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin((form) ->
                        form
                                .usernameParameter("username")
                                .passwordParameter("password")
                                .loginPage("/members/login")
                                .loginProcessingUrl("/login")
                                .failureUrl("/members/login?error=true")
                                .defaultSuccessUrl("/", true)
                                .permitAll()
                )
                .userDetailsService(customUserDetailsService)
                .logout(logout ->
                        logout
                                .logoutUrl("/logout") //로그아웃 처리 URL
                                .logoutSuccessUrl("/") //로그아웃 성공 후 리다이렉트 할 URL
                                .invalidateHttpSession(true)
                                .deleteCookies("JSESSIONID")
                                .permitAll()
                )
                .csrf(csrf ->
                        csrf
                                .ignoringRequestMatchers("/api/**") // /api/** 경로에 대한 CSRF 보호를 비활성화
                );

        return http.build();
    }

}

- 3. Service 구성

@Service
@AllArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    /** Create Member **/
    @Transactional
    public Member createMember(MemberDto.Request dto) {
        //1. 중복 검사 (username, email, phone)
        duplicateValidation(dto);

        //2. 'password' 이중 검사
        passwordDoubleCheck(dto);

        //3. create member
        return memberRepository.save(dto.toEntity());
    }
    /* 1. 중복 검사 (username, email, phone) */
    public void duplicateValidation(MemberDto.Request dto) {
        //username 중복 확인
        if (memberRepository.findByUsername(dto.getUsername()).isPresent()) {
            throw new DataAlreadyExistsException("이미 존재하는 'username' 입니다.");
        }

        //email 중복 확인
        if (memberRepository.findByUsername(dto.getEmail()).isPresent()) {
            throw new DataAlreadyExistsException("이미 존재하는 'email' 입니다.");
        }

        //phone 중복 확인
        if (memberRepository.findByUsername(dto.getPhone()).isPresent()) {
            throw new DataAlreadyExistsException("이미 존재하는 'phone' 입니다.");
        }
    }
    /* 2. 'password' 이중 검사 */
    public void passwordDoubleCheck(MemberDto.Request dto) {
        if (!dto.getPassword().equals(dto.getPassword2())) {
            throw new PasswordCheckFailedException("비밀번호가 동일하지 않습니다.");
        }
    }

}

Member 를 생성하고 저장하기 위한 메서드 createMember 를 구성하였다.

'username', 'email', 'phone' 의 중복 검사 기능과 password 를 정확하게 입력하였는지 이중 검사 기능을 포함한다.


- 4. Controller 구성

@Controller
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    /**
     * 로그인(Login) - "/members/login"
     */
    @GetMapping("/login")
    public String loginForm(Model model) {
        model.addAttribute("loginForm", new MemberDto.Request());

        return "members/login";
    }
}

- 5. VIEW

- login.html

<!DOCTYPE html>
<html lang="kr" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Login Page</title>
</head>
<body>

<h2>로그인</h2>

<form th:action="@{/login}" th:object="${loginForm}" method="post">

    <div>
        <input type="text" id="floatingInput" placeholder="username"
               name="username" th:field="*{username}" required="">
        <label for="floatingInput">Username</label>
    </div>

    <div>
        <input type="password" id="floatingPassword" placeholder="Password" autocomplete="off"
               name="password" th:field="*{password}" required="">
        <label for="floatingPassword">Password</label>
    </div>

    <button type="submit">Login</button>

    <!-- Error message -->
    <div th:if="${errorMessage}">
        <p th:text="${errorMessage}">Error message here</p>
    </div>

</form>


</body>
</html>

 

+) Test Data 입력

// Test Data 생성
@Profile("local")
@Component
@RequiredArgsConstructor
public class InitData {

    private final InitUserService initUserService;


    @PostConstruct
    public void init() {
        initUserService.init();
    }


    @Component
    static class InitUserService {
        @PersistenceContext
        private EntityManager em;
        @Autowired
        private PasswordEncoder passwordEncoder;


        @Transactional
        public void init() {

            String username = "member1";
            String password = "1234";
            MemberDto.Request dto = new MemberDto.Request();
            dto.setUsername(username);
            dto.setPassword(passwordEncoder.encode(password));
            dto.setRole(MemberRole.USER);
            em.persist(dto.toEntity());
        }
    }
}

username : "member1"

password : "1234"

회원 정보를 저장한다.


- 6. 실행 화면

- DB에 저장된 회원 데이터

 

로그인 페이지 - "/members/login"

username : "member1"

password : "1234"

회원 ID / PW 를 입력하여 로그인 시도한다.

 

로그인 성공시 이동 URL로 설정된 "/" 로 이동하였다.

즉, 로그인에 성공한 것을 볼 수 있다.