hyeonga_code

Project_10_SpringSecurity, JWT 없이 사용자 인증 구현하기(토큰) 본문

Project_HYEONGARL

Project_10_SpringSecurity, JWT 없이 사용자 인증 구현하기(토큰)

hyeonga 2024. 6. 8. 05:59
반응형

 

피드백.

멘토님께 코드 피드백을 받았다. SpringSecurity와 JWT를 왜 사용했는지 물어보셨다.

멘토님께서는 지금까지 실무에서 작업하시면서 Spring Security를 사용한 적이 없다고 하셨다.

또한 현재 작업해둔 코드에서는 refreshToken을 구현하지 않고 데이터베이스에 토큰 정보를 저장하는 로직이 아닌 userId를 Authentication에 저장하여 토큰에 값이 들어가있다. 

토큰을 인증 기반으로 하는 서비스는 token에 어떠한 의미도 없는 일회성 토큰을 주로 사용한다고 하셨다.

작업했던 예시를 들어주시며 말씀해주신 바를 토대로 토큰에 의미가 들어간다면 인증되지 않은 사용자의 서버 사용 히스토리를 가지는 토큰을 데이터베이스에 저장해두고 url에 넘겨 url을 호출시 이전에 접근했던 기록들이 남아있는 상태를 불러올 수 있도록 하기 위해 사용했다고 이해했다.

 

SpringSecurity와 JWT를 사용한 이유는 별게 없었다. 참고했던 도서와 구글링을 통한 검색에서 많은 분들이 사용한 기술이었기에 실무에 필요한 기술이라 생각했고, 알고 있어야 하는 기술이라 생각해서 적용시켰던 부분이었다.

 

 

진행 방향.

실무에서 많이 사용되지 않는 다는 것을 알았고, 제대로 이해한 부분이 맞는지 확신할 수 없어 SpringSecurity와 JWT로 구현한 부분을 들어내려고 한다.

토큰은 UUID로 생성하고, UUID만 사용하는 경우 중복되는 경우가 간혹 생길 수 있어 현재 시간을 붙여 최대한 겹치는 경우의 수를 줄이려고 했다.

 


build.gradle

SpringSecurity와 JWT 관련 라이브러리를 모두 제거

 

> 기존 코드

더보기
plugins {	// Gradle에서 지원하는 플러그인
	id 'java'
	id 'org.springframework.boot' version '3.2.5'
	id 'io.spring.dependency-management' version '1.1.5'
	id "io.freefair.lombok" version "8.6"

	/*
        1) id 'java'
            java 코드 컴파일, 테스트, 애플리케이션 패키징
        2) id 'org.springframework.boot' version '3.2.5'
            SpringBoot 애플리케이션을 실행, 실행 가능한 JAR 또는 WAR패키징
        3) id 'io.spring.dependency-management' version '1.1.5'
            일관된 방식으로 종속성을 관리하는 데 사용
            섹션을 사용하여 프로젝트의 모든 종속성에 적용해야 하는 종속성 버전을 정의하여 충돌을 방지
        4) id "io.freefair.lombok" version "8.6"
            롬복 사용 (gradle에서 관리하므로 플러그인으로 설정 가능)
    */
}

// 프로젝트 그룹 ID 설정 (패키지 이름 지정 및 종속성 관리에 사용)
group = 'com.heyongarl'

// 프로젝트의 버전을 설정
version = '1.0-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
	/*
        toolchain VS sourceCompatibility
        toolchain
            Gradle 자체를 실행하는 데 사용되는 JDK와 별개로
            프로젝트를 컴파일, 테스트, 실행하는 데 사용할 JDK 버전을 지정할 수 있다.
        sourceCompatibility
            소스 코드의 Java 버전 호환성을 명시하는 데 사용
    */
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-web-services'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

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

	// MySQL JPA
	runtimeOnly 'com.mysql:mysql-connector-j'

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

	// Spring Security
	implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
	testImplementation 'org.springframework.security:spring-security-test:5.7.11'

	/*
        1) implementation 'org.springframework.boot:spring-boot-starter-web'
            Spring MVC를 사용하여 웹 애플리케이션을 빌드하기 위한 종속성
        2) implementation 'org.springframework.boot:spring-boot-starter-web-services'
            웹 서비스 제작을 지원
        3) implementation 'org.springframework.boot:spring-boot-starter-validation'
            Java Bean의 유효성을 검사하기 위한 종속성 (@NotNull, @Size)
        4) implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
            Spring Data JPA를 포함한 JPA 기반 데이터 엑세스에 대한 지원을 제공
        5) runtimeOnly 'com.mysql:mysql-connector-j'
            MySQL 데이터베이스에 연결하는 데 필요한 MySQL JDBC 드라이버
        6) testImplementation 'org.springframework.boot:spring-boot-starter-test'
            JUnit, Jamcrest, Mockito를 포함한 Spring Boot 애플리케이션 테스트를 위한 종속성
        7) implementation 'org.springframework.boot:spring-boot-starter-security:2.7.17'
        	보안과 관련된 기능을 제공하는 스프링 보안 모듈에 대한 스타터 의존성
        	인증(Authentication)과 권한 부여(Authorization)을 위한 기능을 제공
        	웹 애플리케이션의 보안을 강화하는데 사용
        8) testImplementation 'org.springframework.security:spring-security-test:5.7.11'
        	Spring Security 테스트에 필요한 라이브러리를 포함하는 의존성
        	인증 및 권한 부여를 테스트하거나 보안 관련 기능을 검증할 수 있음
    */
}

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

 

> 수정 코드

더보기
plugins {	// Gradle에서 지원하는 플러그인
	id 'java'
	id 'org.springframework.boot' version '3.2.5'
	id 'io.spring.dependency-management' version '1.1.5'
	id "io.freefair.lombok" version "8.6"

	/*
        1) id 'java'
            java 코드 컴파일, 테스트, 애플리케이션 패키징
        2) id 'org.springframework.boot' version '3.2.5'
            SpringBoot 애플리케이션을 실행, 실행 가능한 JAR 또는 WAR패키징
        3) id 'io.spring.dependency-management' version '1.1.5'
            일관된 방식으로 종속성을 관리하는 데 사용
            섹션을 사용하여 프로젝트의 모든 종속성에 적용해야 하는 종속성 버전을 정의하여 충돌을 방지
        4) id "io.freefair.lombok" version "8.6"
            롬복 사용 (gradle에서 관리하므로 플러그인으로 설정 가능)
    */
}

// 프로젝트 그룹 ID 설정 (패키지 이름 지정 및 종속성 관리에 사용)
group = 'com.heyongarl'

// 프로젝트의 버전을 설정
version = '1.0-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
	/*
        toolchain VS sourceCompatibility
        toolchain
            Gradle 자체를 실행하는 데 사용되는 JDK와 별개로
            프로젝트를 컴파일, 테스트, 실행하는 데 사용할 JDK 버전을 지정할 수 있다.
        sourceCompatibility
            소스 코드의 Java 버전 호환성을 명시하는 데 사용
    */
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-web-services'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

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

	// MySQL JPA
	runtimeOnly 'com.mysql:mysql-connector-j'

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

	/*
        1) implementation 'org.springframework.boot:spring-boot-starter-web'
            Spring MVC를 사용하여 웹 애플리케이션을 빌드하기 위한 종속성
        2) implementation 'org.springframework.boot:spring-boot-starter-web-services'
            웹 서비스 제작을 지원
        3) implementation 'org.springframework.boot:spring-boot-starter-validation'
            Java Bean의 유효성을 검사하기 위한 종속성 (@NotNull, @Size)
        4) implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
            Spring Data JPA를 포함한 JPA 기반 데이터 엑세스에 대한 지원을 제공
        5) runtimeOnly 'com.mysql:mysql-connector-j'
            MySQL 데이터베이스에 연결하는 데 필요한 MySQL JDBC 드라이버
        6) testImplementation 'org.springframework.boot:spring-boot-starter-test'
            JUnit, Jamcrest, Mockito를 포함한 Spring Boot 애플리케이션 테스트를 위한 종속성
    */
}

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

 

> build

 

 


ApiFilter

기존에 작업했던 TokenAuthenticationFilter와 SecurityFilterChain을 삭제했다.

 

> 작성  코드

package com.hyeongarl.config;

import com.hyeongarl.error.UserUnInvalidException;
import com.hyeongarl.service.TokenService;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class ApiFilter extends OncePerRequestFilter {

    private final TokenService tokenService;

    // HTTP 요청에서 토큰을 추출하여 유효성을 검사하고 인증되지 않은 경우 예외를 발생
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("token");

        if(token == null) {
            throw new UserUnInvalidException();
        }
        filterChain.doFilter(request, response);
    }
}

 

 


PasswordEncoder

SpringSecurity 라이브러리를 제거하게 되면 ByCryptPasswordEncoder도 사용할 수 없게 된다.

하여 Password를 암호화하는 로직을 작성

 

Spring Security 없이 PasswordEncoder 구현하기

사이드 프로젝트를 진행하며 회원가입 시 패스워드 암호화에 대해 구현했습니다. 이 포스트에서는 암호화에 대한 간략한 내용과 저의 암호화 구현기에 대한 내용을 적어보도록 하겠습니다. 일

wonchan.tistory.com

 

 

> 작성  코드

package com.hyeongarl.config;

import org.springframework.stereotype.Component;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Base64;

@Component
public class PasswordEncoder {
    // PBKDF2 알고리즘을 사용하여 비밀번호를 암호화
    public static String encrypt(String userEmail, String password) {
        System.err.println("PasswordEncoder");
        try {
            KeySpec spec = new PBEKeySpec(password.toCharArray(), getSalt(userEmail), 85319, 128);
            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");

            byte[] hash = factory.generateSecret(spec).getEncoded();
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException | InvalidKeySpecException e) {
            throw new RuntimeException(e);
        }
    }

    private static byte[] getSalt(String userEmail)
            throws NoSuchAlgorithmException, UnsupportedEncodingException {

        MessageDigest digest = MessageDigest.getInstance("SHA-512");
        byte[] keyBytes = userEmail.getBytes("UTF-8");

        return digest.digest(keyBytes);
    }
}

 

 


ApiConfig

> 작성  코드

package com.hyeongarl.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.hyeongarl.service.TokenService;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ApiConfig {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return mapper;
    }
    
    @Bean   // Filter 설정
    public FilterRegistrationBean<ApiFilter> filterRegistrationBean(TokenService tokenService) {
        FilterRegistrationBean<ApiFilter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new ApiFilter(tokenService));
        filterRegistrationBean.addUrlPatterns("/url");
        filterRegistrationBean.addUrlPatterns("/category");
        filterRegistrationBean.setOrder(Integer.MIN_VALUE); // 적용 순서
        filterRegistrationBean.setName("ApiFilter");

        return filterRegistrationBean;
    }
}

 

 


Token

> 작성  코드

package com.hyeongarl.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = "token")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Token {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long tokenId;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(name = "expiry_date", nullable = false)
    private LocalDateTime expiryDate;

    @PrePersist
    protected void onCreate() {
        expiryDate = LocalDateTime.now().plusDays(1);
    }
}

 

 


TokenRepository

Delete 문을 작성하기 위해서는 @Modifying 어노테이션과 @Transactional 어노테이션이 필요하다.

 

> 작성  코드

package com.hyeongarl.repository;

import com.hyeongarl.entity.Token;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Repository
public interface TokenRepository extends JpaRepository<Token, Long> {

    @Query("SELECT t " +
            "FROM Token t " +
            "WHERE t.userId= :userId " +
            "AND t.expiryDate > :now")
    Token findByUserIdAndExpiryDate(Long userId, LocalDateTime now);

    @Modifying
    @Transactional
    @Query("DELETE " +
            "FROM Token " +
            "WHERE userId= :userId " +
            "AND expiryDate < :now")
    void deleteExpiredTokenByUserIdAndExpiryDate(@Param("userId") Long userId,
    							@Param("now")LocalDateTime now);

    @Query("SELECT " +
            "CASE WHEN COUNT(t) > 0 THEN true " +
            "ELSE false END " +
            "FROM Token t " +
            "WHERE t.userId= :userId " +
            "AND t.expiryDate < :now")
    boolean existsByUserIdAndExpiryDate(Long userId, LocalDateTime now);
}

 

 

 

TokenService

> 작성  코드

package com.hyeongarl.service;

import com.hyeongarl.entity.Token;
import com.hyeongarl.entity.User;
import com.hyeongarl.error.UserNotFoundException;
import com.hyeongarl.repository.TokenRepository;
import com.hyeongarl.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class TokenService {

    private final TokenRepository tokenRepository;
    private final UserRepository userRepository;

    public String login(User userRequest) {
        LocalDateTime now = LocalDateTime.now();

        // 사용자 정보 확인
        User user = userRepository.findByUserEmail(userRequest.getUserEmail())
                .orElseThrow(UserNotFoundException::new);;

        // 비밀번호 확인
        if(!user.getPassword().equals(userRequest.getPassword())) {
            throw new UserNotFoundException();
        }

        if(tokenRepository.existsByUserIdAndExpiryDate(user.getUserId(), now)) {
            tokenRepository.deleteExpiredTokenByUserIdAndExpiryDate(user.getUserId(), now);
        }

        Token validateToken = tokenRepository.findByUserIdAndExpiryDate(user.getUserId(), now);

        if(validateToken == null) {
            String token = UUID.randomUUID().toString() + "-" + System.currentTimeMillis();
            Token saveToken = Token.builder()
                    .token(token)
                    .userId(user.getUserId())
                    .build();

            tokenRepository.save(saveToken);
            return token;
        }
        return validateToken.getToken();
    }
}

 

 

 

AuthController

> 작성  코드

package com.hyeongarl.controller;

import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.service.TokenService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
    private final TokenService tokenService;

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody UserRequestDto userRequest) {

        return ResponseEntity.ok().header("token",
                        tokenService.login(userRequest.toEntity()))
                .body("로그인 성공");
    }

    @PostMapping("/logout")
    public ResponseEntity<String> logout() {
        return ResponseEntity.ok("로그아웃이 성공적으로 처리되었습니다.");
    }
}

 



Test

> AuthControllerTest

package com.hyeongarl.controller;

import com.hyeongarl.dto.UserRequestDto;
import com.hyeongarl.dto.UserResponseDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AuthControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("login_success")
    void testLogin_success() {
        UserRequestDto userRequest 
                = new UserRequestDto("login@email.com", "loginPassword");

        ResponseEntity<UserResponseDto> saveUser
                = restTemplate.postForEntity("/user", userRequest, UserResponseDto.class);
        ResponseEntity<String> responseEntity 
                = restTemplate.postForEntity("/login", userRequest, String.class);

        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isNotNull();
    }
}

 

> 실행

 

 

TestCode

 

 

> 기존 코드

더보기
    @Autowired
    private TokenProvider tokenProvider;

    @BeforeEach
    void setUp() {
        User testUser = User.builder()
                .userId(1L)
                .userEmail("testUser@email.com")
                .password("testUserPassword")
                .userRegdate(LocalDateTime.now())
                .build();

        String token = tokenProvider.generatedToken(testUser, Duration.ofDays(10));
        headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + token);

        categoryRequest = CategoryRequestDto.builder()
                .categoryName("testCategoryName")
                .parentCategoryId(null)
                .build();

        requestEntity = new HttpEntity<>(categoryRequest, headers);
    }

 

 

> 작성  코드


    @Autowired
    private TokenService tokenService;

    @Autowired
    private UserService userService;

    @BeforeEach
    void setUp() {
        User testUser = User.builder()
                .userId(1L)
                .userEmail("testUser@email.com")
                .password("testUserPassword")
                .userRegdate(LocalDateTime.now())
                .build();

        userService.save(testUser);
        String token = tokenService.login(testUser);
        headers = new HttpHeaders();
        headers.set("token", token);

        categoryRequest = CategoryRequestDto.builder()
                .categoryName("testCategoryName")
                .parentCategoryId(null)
                .build();

        requestEntity = new HttpEntity<>(categoryRequest, headers);
    }
반응형