쇼핑몰 웹사이트 만들어보기 - 스프링 시큐리티를 이용한 로그인 재구현

Shiba·2024년 7월 31일
0

프로젝트 및 일기

목록 보기
11/29
post-thumbnail

이번 시간에는 오류나 기타 불편사항을 수정해보자

지금까지 찾은 오류는 다음과 같다.

  • 로그인을 하지 않고 장바구니에 추가를 클릭할 시 아무일도 일어나지 않음
  • 로그인을 하지 않고, 판매하기에 들어갈 수 있음. 정보 입력 시 오류 발생
  • 위 행위를 한 후, 로그인을 하려고 들어가면 오류가 발생
  • 아이디/비밀번호 찾기 부분이 추가되지않음
  • 카테고리에서 해당 카테고리를 클릭하면 category로 쿼리가 가지 않고, query로 가버림

기타 불편사항은 다음과 같다.

  • 새로 추가된 html들은 css가 없음
  • 로그인을 할 때, 엔터키를 눌러서 로그인을 할 수 없음

로그인 오류는 세션이 로그인 버튼을 눌렀을 때, 새로 생성된다는 점에서 오류가 발생하는 것 같다. 해당 오류는 로그인 기능을 스프링 시큐리티로 구현하면 해결될 것 같다.

20240731) 스프링 시큐리티가 버전 이슈가 있어서 생각보다 오래걸렸다.. 스프링 시큐리티를 어느정도 이해하고 했는데도 추가해야하는 빈이 달라서 많은 시행착오를 거쳤다.
(스프링 시큐리티도 조만간 따로 포스팅하면서 복습해야겠다)

사용한 버전

  • 스프링 부트 3.2.3
  • 스프링 시큐리티 6.3.1.2
  • 자바 17

스프링 시큐리티 5.7.X ~ 6.X 초반 버전에서는 UserDetailService를 구현해서 빈으로 설정했었다.

하지만 직접 해보니 스프링 시큐리티 6.3.X 버전에서는 UserDetailService는 구현은 하되, Bean으로 추가하지 않아야 되는 것 같았다. Bean으로 추가한건 PasswordEncoder 뿐이다.

build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.3'
	id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	implementation 'mysql:mysql-connector-java:8.0.33'

	implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.4'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/User
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=0000
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=false
file.upload-dir=/path/to/upload/directory
spring.security.user.name=id
spring.security.user.password=password
logging.level.org.springframework.security=DEBUG

SpringConfig (기존에 있던 Config에 Security를 통합해서 사용했다)

package com.shoppingmall;

import com.shoppingmall.repository.*;
import com.shoppingmall.service.CartService;
import com.shoppingmall.service.FileStorageService;
import com.shoppingmall.service.ProductService;
import com.shoppingmall.service.UserService;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SpringConfig {
    @PersistenceContext
    public EntityManager em;
    @Bean
    public FileStorageService fileStorageService() { return new FileStorageService(); };

    @Bean
    public CategoryRepository categoryRepository() { return  new CategoryRepository(em); };

    @Bean
    public UserRepository userRepository(){
        return new MemoryUserRepository(em);
    }
    @Bean
    public UserService userService(){
        return new UserService(userRepository());
    }

    @Bean
    public ProductRepository productRepository() { return new MemoryProductRepository(em); };
    @Bean
    public ProductService productService() { return new ProductService(productRepository(), fileStorageService(), categoryRepository()); }

    @Bean
    public CartRepository cartRepository() { return new CartRepository(em); }

    @Bean
    public CartService cartService() { return new CartService(cartRepository(), userRepository(), productRepository()); }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf((csrf) -> csrf.disable())
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/css/**", "/js/**", "/images/**", "/json/**").permitAll() // CSS 파일에 대한 접근을 허용
                        .requestMatchers("/user/status", "/products/add", "/cart/**").authenticated()
                        .anyRequest().permitAll())
                .formLogin(formLogin -> formLogin
                        .loginPage("/login")
                        .loginProcessingUrl("/login-process")
                        .usernameParameter("loginId")	// [C] submit할 아이디
                        .passwordParameter("password")
                        .permitAll()
                        .defaultSuccessUrl("/") // 성공 시 리다이렉트 URL
                        .failureUrl("/login?error") // 실패 시 리다이렉트 URL
                )
                .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .sessionFixation().newSession() // 세션 고정 보호
                )
                .httpBasic(Customizer.withDefaults())
                .logout((logout) -> logout
                        .logoutUrl("/logout") // 로그아웃
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true));// 세션 무효화

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }


}

LoginController (이전에 만든 로그인 관련 매핑을 옮기면서 수정을 좀 했다)

package com.shoppingmall.controller;

import com.shoppingmall.domain.Users;
import com.shoppingmall.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.Map;

@Slf4j
@Controller
public class LoginController {

    @Autowired
    private UserService userService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * [View] 로그인 페이지를 엽니다.
     */
    @GetMapping("/login")
    public String login() {
        return "/user/login";
    }

    /**
     * [Action] 로그인 프로세스를 동작시킨다.
     */

    /*@PostMapping("/user/login")
    public String login(LoginRequest loginRequest) {
        boolean isValidMember = userSecurityService.isValidUser(loginRequest.getLoginId(), loginRequest.getPassword());
        if (isValidMember)
            return "/user/status";
        return "/user/login";
    }
    /**
     * [Action] 로그아웃 프로세스를 동작시킨다.
     */
    @GetMapping("/user/logout")
    public String logout(HttpServletResponse response) {
        // JWT 토큰을 저장하는 쿠키의 값을 삭제
        Cookie jwtCookie = new Cookie("jwt", null);
        jwtCookie.setMaxAge(0);  // 쿠키의 유효기간을 0으로 설정하여 즉시 삭제
        jwtCookie.setPath("/");
        response.addCookie(jwtCookie);

        return "redirect:/login";  // 로그인 페이지로 리다이렉트
    }

    @PostMapping("/user/new")
    public ResponseEntity<String> registerUser(@RequestBody Users users) {
        Users savedUser = null;
        ResponseEntity response = null;
        try {
            String hashPwd = passwordEncoder.encode(users.getPassword());
            users.setPassword(hashPwd);
            if(userService.findById(users.getId()) == null) {
                savedUser = userService.join(users);
                if (savedUser.getId() != null) {
                    response = ResponseEntity
                            .status(HttpStatus.CREATED)
                            .body(Map.of("message", "success"));
                }
            }
            else{
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("중복된 ID입니다");
            }
        } catch (Exception ex) {
            response = ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("An exception occured due to " + ex.getMessage());
        }
        return response;
    }
}

UserService에 해당 메소드 오버라이드 (UserDetailsService를 상속해야함)

public class UserService implements UserDetailsService {

	@Override
    public UserDetails loadUserByUsername(String insertedUserId) throws UsernameNotFoundException {
        Optional<Users> findOne = Optional.ofNullable(userRepository.findById(insertedUserId));
        Users users = findOne.orElseThrow(() -> new UsernameNotFoundException("없는 회원입니다 ㅠ"));

        return User.builder()
                .username(users.getId())
                .password(users.getPassword())
                .roles(users.getRole())
                .build();
    }

위의 클래스까지 수정하면 로그인은 된다. 하지만 회원가입은 추가적인 수정이 필요했다.

회원가입페이지 js코드 수정

const id = document.getElementById("id").value;
        const password = document.getElementById("pw").value;
        const name = document.getElementById("name").value;
        const birth = document.getElementById("birth").value;
        const phone =
            document.getElementById("phone1").value +
            document.getElementById("phone2").value +
            document.getElementById("phone3").value ;
        const email = document.getElementById("email").value;
        const place = document.getElementById("place").value;
        const enabled = "T";
        const role = "user";
        const data = {
            id: id,
            password: password,
            name: name,
            birth: birth,
            phone: phone,
            email: email,
            place: place,
            enabled:enabled,
            role:role
        };
        $.ajax({
            url: '/user/new',
            type: 'post',
            contentType: 'application/json',
            data: JSON.stringify(data),
            success: function(response) {
                // 성공적인 응답 처리
                if (response.message === "success") { // HTTP Created status
                    window.location.href = "/user/complete?id="+encodeURIComponent(id); // 성공 후 리다이렉트할 경로
                }
                },
            error: function(jqXHR, textStatus, errorThrown) {
                // 에러 처리
                alert("Error: " + jqXHR.responseText);
                console.log("Error: ", textStatus, errorThrown);
            }
        });
    });

이제 로그인을 스프링 시큐리티를 이용하여 할 수 있게 되었다. 그덕에 이전에는 js로 구현했던 로그인 관련 함수들을 모두 지워도 되며, 이전에 아이콘에서 로그인 상태면 로그인한 아이디의 정보창으로 넘어가도록 js로 구현했었는데 이제는 스프링 시큐리티에서 상태창에 접근하려고할 시, 로그인 창으로 바로 넘겨버릴 것이므로 더욱 쉽게 원하는 로직을 구현할 수 있게 되었다.
즉,

  • 로그인을 하지 않고, 판매하기에 들어갈 수 있음. 정보 입력 시 오류 발생
  • 위 행위를 한 후, 로그인을 하려고 들어가면 오류가 발생

이 두 가지의 오류는 해결이 된 셈이다.

  • 로그인을 하지 않고 장바구니에 추가를 클릭할 시 아무일도 일어나지 않음

이 오류는 조금 더 공부해봐야할 것 같다. post로 오는 정보에 id가 없음을 이용하여 오류를 발생시키면 될 것 같긴하다.

또한, 해당 오류도 수정을 했다.

  • 카테고리에서 해당 카테고리를 클릭하면 category로 쿼리가 가지 않고, query로 가버림
function renderCategories(categories) {
        // 카테고리 메뉴 요소 가져오기
        category_div.classList.add("category_all_layer");

        // 카테고리 메뉴와 세부 카테고리 메뉴를 부모 요소에 추가
        category_div.appendChild(category_1depth);
        category_box.appendChild(category_div);
        //console.log(categories);
        // 카테고리 데이터를 동적으로 HTML에 추가
        categories.forEach(ajaxCategory => {
            const categoryItem = document.createElement("li");
            categoryItem.textContent = JSON.stringify(ajaxCategory.name).replace(/"/g, '');
            categoryItem.classList.add("category_item");

            const category_2depth = document.createElement("div");
            category_2depth.id = "category_2depth";
            categoryItem.appendChild(category_2depth);

            ajaxCategory.detail.forEach(detail => {
                const detailItem = document.createElement("li");
                detailItem.textContent = detail;
                detailItem.classList.add("detail_item");
                category_2depth.appendChild(detailItem);

                detailItem.addEventListener("click", () => {
                    // 사용자가 카테고리를 클릭했을 때 실행될 함수 호출
                    redirectSearchCategory(detail);
                    event.stopPropagation();
                });
            });

            // 마우스가 카테고리에 올라갔을 때 세부 카테고리 표시
            categoryItem.addEventListener("mouseenter", () => {
                categoryItem.id = "category_item_active";
                categoryItem.addEventListener("mouseenter", () => {
                    category_2depth.style.display = "inline-block";
                });
                categoryItem.addEventListener("mouseleave", () => {
                    category_2depth.style.display = "none";
                    categoryItem.id = "category_item";
                });
            });

            // 마우스가 카테고리에서 벗어났을 때 세부 카테고리 숨기기
            category_box.addEventListener("mouseleave", () => {
                category_2depth.style.display = "none";
            });

            categoryItem.addEventListener("click", () => {
                // 사용자가 카테고리를 클릭했을 때 실행될 함수 호출
                let text = '';
                const element = document.getElementById("category_item_active");
                // 자식 노드를 제외한 텍스트를 수집합니다.
                for (let i = 0; i < element.childNodes.length; i++) {
                    const node = element.childNodes[i];
                    if (node.nodeType === Node.TEXT_NODE) {
                        text += node.textContent;
                    }
                }
                redirectSearchCategory(text.trim());
            });
            category_1depth.appendChild(categoryItem);
        });
    }

category로 가지 않던건 캐시문제였던 것이였고, 캐시를 지우고 해보니 부모 태그 선택 시, 자식 텍스트까지 모조리 끌고 오길래 trim을 이용하여 다 자르는 방법으로 부모 태그를 사용할 수 있게하였다.

++)

An exception occured due to No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call

오류는 Service 클래스 @Transactional 애노테이션 확인

글이 코드 때문에 너무 길어진 것 같다.
다음 글에서 남은 수정거리들을 수정해주도록 하자


참고자료(스프링 시큐리티 관련)

https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/

https://nahwasa.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-30%EC%9D%B4%EC%83%81-Spring-Security-%EA%B8%B0%EB%B3%B8-%EC%84%B8%ED%8C%85-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0

profile
모르는 것 정리하기

0개의 댓글