본문 바로가기
프로젝트/Board 프로젝트

[JPA] 게시판 프로젝트 - 로그인 및 게시판 메인화면 구현, Spring Security를 사용한 로그인 (4)

by taetae99 2025. 1. 17.
반응형

 

 

 

 

이전 개발 단계 보러 가기 

https://taetae99.tistory.com/30

 

벡엔드 토이 프로젝트 (게시판) - 게시글,댓글 기능 개발 및 테스트 (3)

이전 개발 단계 보러 가기https://taetae99.tistory.com/28 벡엔드 토이 프로젝트 (게시판) - 도메인 설계 및 회원 기능 개발 및 테스트 (2)이전 개발 단계 보러 가기https://taetae99.tistory.com/27 벡엔드 토이

taetae99.tistory.com

 

 

 

목차

     

     

     


     

    1. 게시판 메인 화면 구현

     

    화면 구현 없이 개발을 진행하다 보니 한계를 느꼈고 메인 화면부터 차근차근 구현하며 개발을 하기로 했다.

     

     

    1. 메인 화면

     

    <로그인 시> 

    로그인 시 메인화면

    <비로그인 시>

    비 로그인시 메인화면

     

     

    비로그인과 로그인에는 네비게이션 바 버튼의 기능에 차이점이 존재한다.

    로그인 시에는 네비게이션 바에 닉네임, 마이페이지, 로그아웃 버튼이 활성화 된다.

    비로그인 시에는 내비게이션 바에 회원가입, 로그인 버튼이 활성화된다.

     

    비로그인과 로그인을 구별하는 방법은 Spring Security와 Thymeleaf의 통합 라이브러리인

    Thymeleaf Extras Spring Security5에서 제공하는 속성을 사용했다. 

     

    -> 이 부분은 따로 의존성을 추가해야 한다. 자세한 방법은 구글에 Thymeleaf Extras Spring Security5 검색!

     

     

    아래는 작성된 일부 코드이다. 
    로그인 시에는 sec:authrize의 isAuthenticated()를 사용하여 인증하고

    비로그인 시에는 sec:authrize의 isAnonymous()를 사용한다.

    <!--인증된 사용자-->
            <div class="d-flex" sec:authorize="isAuthenticated()">
                <p th:text="${nickname}" style="margin-right: 1.5rem; font-size: 1.2rem; font-weight: bold; color: #2F4F4F;"></p>
                <a th:href="@{/mypage}" class="btn btn-outline-primary me-2">마이페이지</a>
                <a th:href="@{/logout}" class="btn btn-outline-primary me-2">로그아웃</a>
            </div>
    <!--        인증되지 않은 사용자-->
            <div class="d-flex" sec:authorize="isAnonymous()">
                <a th:href="@{/auth/register}" class="btn btn-outline-primary me-2">회원 가입</a>
                <a th:href="@{/auth/login}" class="btn btn-outline-primary">로그인</a>
            </div>

     

     

    2. 게시글 보기

     

    게시글의 내용은 회원만 열람할 수 있고, 게시글의 제목은 비회원, 회원 모두 확인할 수 있도록 한다.

     

    게시글은 타임리프 문법을 사용하여 전체 게시글(posts)을 불러오고 제목, 작성자, 생성일자, 조회수가 보이도록 하였다. 

    <table class="table table-striped">
            <thead>
            <tr>
                <th scope="col">제목</th>
                <th scope="col">작성자</th>
                <th scope="col">작성일</th>
                <th scope="col">조회수</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="post : ${posts}">
                <td th:text="${post.title}">게시글 제목</td>
                <td th:text="${post.writer}">작성자</td>
                <td th:text="${post.createdDate}">2025-01-09</td>
                <td th:text="${post.viewCount}">조회수</td>
            </tr>
            </tbody>
        </table>

     

     

    게시글을 작성해 본 부분이다. 테스트를 위해 임의로 하드코딩하여 데이터를 입력하였다.
    게시글을 눌렀을 때 게시글의 내용을 볼 수 있도록 하고 각 항목의 크기 조정 및 작성일에 대한 format 과정이 필요하다. 

     

     

     

    2. 회원 가입 

     

     

    회원 가입 페이지이다.

    회원 가입 페이지

     

     

    이전에 작성했던 기능 요구사항을 보자.

    요구사항 명세서
    요구사항

    회원 가입 시 필요한 정보는 이메일, 이름, 닉네임, 비밀번호를 필요로 한다.

    검증과 관련된 부분은 Spring Secutity 연동을 마친 후 진행한다.

     

     

     

    3. 로그인 페이지

     

    로그인 페이지이다.

    로그인 페이지

     

    로그인 시 이메일과 비밀번호를 필요로 한다. 

    <div class="container mt-5">
        <h2 class="text-center mb-4">로그인</h2>
        <form th:action="@{/auth/login}" th:object="${loginForm}" method="post">
            <div class="mb-3">
                <label th:for="email" class="form-label">이메일</label>
                <input type="email" class="form-control" th:field="*{email}" placeholder="이메일을 입력하세요">
            </div>
            <div class="mb-3">
                <label th:for="password" class="form-label">비밀번호</label>
                <input type="password" class="form-control" th:field="*{password}" placeholder="비밀번호를 입력하세요">
            </div>
            <div class="d-grid gap-2">
                <button type="submit" class="btn btn-primary">로그인</button>
            </div>
        </form>
        <div class="text-center mt-3">
            <p>계정이 없으신가요? <a th:href="@{/auth/register}">회원 가입</a></p>
        </div>
    </div>

     

     

     

    4. Spring Security 설정

     

    자세한 설명은 spring security 공식문서를 확인해 주시길 바란다.

    https://docs.spring.io/spring-security/reference/index.html

     

    Spring Security :: Spring Security

    If you are ready to start securing an application see the Getting Started sections for servlet and reactive. These sections will walk you through creating your first Spring Security applications. If you want to understand how Spring Security works, you can

    docs.spring.io

     

    1. UserDetails 구현한 MemberDetail

    public class MemberDetail implements UserDetails {
        private final Member member;
    
        public MemberDetail(Member member) {
            this.member = member;
        }
    
        //권한 목록을 반환한다.
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return Collections.emptyList();
        }
    
        @Override
        public String getPassword() {
            return member.getPassword();
        }
    
        @Override
        public String getUsername() {
            return member.getEmail();
        }
    
        public String getNickname() {
            return member.getNickname();
        }
    
    }

     

    SpringSecurity에서 사용자의 정보를 담는 역할을 UserDetails가 한다. 

    인증과 도메인 정보를 명확히 분리하기 위하여 UserDetails를 구현하는 별도의 클래스를 생성하고 Member객체를 내부 필드로 보관한다.

     

    Spring Security에서는 기본적으로 username과 password를 사용해서 로그인을 진행한다. 

    하지만 요구사항에서 아이디 대신 이메일을 사용하기로 했으므로 아이디 대신 이메일을 반환하도록 하였다. 

    또한 권한에 대한 요구사항이 존재하지 않았으므로 권한 목록을 반환하는 getAuthrities에서는 빈 리스트를 반환한다.

     

     

    2. UserDetailsService를 구현한 MemberDetailService 

    @Service
    @RequiredArgsConstructor
    public class MemberDetailService implements UserDetailsService {
    
        private final MemberRepository memberRepository;
        @Override
        public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    
            Member member = memberRepository.findByEmail(email);
    
            if(member == null)
                throw new UsernameNotFoundException("계정을 찾을 수 없습니다.");
            return new MemberDetail(member);
        }
    }

     

     

    💡 UserDetailsService 공식 문서의 내용
    UserDetailsService는 DaoAuthenticationProvider에 의해 사용되며, 사용자 이름과 비밀번호를 사용한 인증을 위해 사용자 이름, 비밀번호 및 기타 속성을 검색하는 데 사용됩니다. Spring Security는 인메모리(in-memory), JDBC, 캐싱(caching) 구현체를 제공합니다.
    사용자는 커스텀 UserDetailsService를 빈(bean)으로 등록하여 인증 방식을 커스터마이징 할 수 있습니다.

     

     

    UserDetailsService의 역할은 로그인 과정에서 사용자의 정보를 로드하고, 인증이 가능한 사용자 객체를 반환한다.

    이메일을 아이디로 사용하기 때문에 MemberRepository에서 이메일을 사용한 멤버 검색이 필요했다.

     

    MemberRepository에 추가해 주자

    public Member findByEmail(String email) {
            return (Member) em.createQuery("select m from Member m where m.email = :email", Member.class)
                    .setParameter("email", email)
                    .getSingleResult();
        }

     

     

     

    3. Spring Security 설정하기 

     

    @Configuration
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                    .csrf(AbstractHttpConfigurer::disable)
                    .authorizeHttpRequests(auth -> auth
                            .requestMatchers("/board","/","/auth/login",
                                    "/auth/register", "/css/**", "/js/**").permitAll()
                            .anyRequest().authenticated()
                    )
                    .formLogin(form -> form
                            .loginPage("/auth/login")
                            .loginProcessingUrl("/auth/login")
                            .defaultSuccessUrl("/board")
                            .failureUrl("/auth/login?error=true")
                            .usernameParameter("email")
                            .passwordParameter("password")
                    )
                    .logout(logout -> logout
                            .logoutUrl("/auth/logout")
                            .logoutSuccessUrl("/auth/login")
                            .invalidateHttpSession(true)
                    );
    
            return http.build();
        }
        
        @Bean
        public BCryptPasswordEncoder bCryptPasswordEncoder() {
            return new BCryptPasswordEncoder();
        }
    }

     

    하나씩 알아보자

    • securityFilterChain은 Spring Security의 핵심 설정을 정의하는 Bean으로 요청 URL의 인증 및 권한 부여, 로그인/로그아웃 페이지, 세션 관리 등을 설정한다.
    • CSRF 필터는(Cross-Site Request Forgery) CSRF 공격을 보호하기 위한 수단으로 Post요청과 같은 안전하지 않은 HTTP메서드에 대한 CSRF 공격을 막는다. 하지만 학습을 목표로 함으로 disable 하였다.
    • authorizeHttpRequests 필터는 요청에 대한 접근 권한을 설정한다. 
      • 내가 허용하고 싶은 페이지는 메인 페이지( /board , / )와 로그인 페이지( /auth/login ) 회원가입 ( /auth/register )이다.
      • /css/** 와 /js/** 는 bootstrap을 사용하고 있기 때문에 적용해 주었다.
      • 위의 페이지들은 인증 없이도 사용할 수 있도록 하고 나머지는 인증이 필요하도록 하였다.
    • formLogin 필터는 로그인과 관련된 설정을 담당한다.
      • email을 인증 아이디로 사용한다고 하였으므로 usernameParameter에 email을 입력했다.
      • loginPage는 로그인 페이지 요청으로 /auth/login의 GET 요청으로 볼 수 있다.
      • loginProcessingUrl는 로그인 처리 요청이 왔을 때 사용된다.
    • logout 필터는 로그아웃과 관련된 설정을 담당한다.

     

    5. 회원 가입 처리하기

     

    회원 가입 요청 시 작동한다.

    @PostMapping("/auth/register")
        public String create(@Valid MemberForm memberForm, BindingResult bindingResult) {
            if (bindingResult.hasErrors()) {
                return "member/memberJoin";
            }
            String password = bCryptPasswordEncoder.encode(memberForm.getPassword());
            Member member = Member.createMember(memberForm.getName(), memberForm.getEmail(),
                    password, memberForm.getNickname());
            memberService.join(member);
    
            return "redirect:/auth/login";
        }

     

     

    @Valid는 회원 가입 시 필요한 정보를 검증하는 데 사용한다. 이는 다음 포스팅에서 다루겠다.

     

    비밀번호를 입력 시 bCryptPasswordEncoder를 사용하여 암호화한다.

     

    아래는 비밀번호가 암호화되어 저장되는 모습이다. 

     

    1. 비밀번호를 12345678로 회원가입을 하였다. 

    비밀번호 입력 창

     

    2. h2 데이터 베이스에는 아래와 같이 암호화되어 저장된다.

    데이터 베이스 저장 모습

     

    3. 비밀번호를 암호화하여 DB에 저장하면 로그인 요청 시 Spring Security는 BCryptPasswordEncoder.matches()를 사용하여 입력한 비밀번호와 암호화되어 저장된 비밀번호를 비교한다.

     

     

     

     

     

     

     

    반응형