hyeonga_code

Project_11_Spring Security 사용해서 사용자 인증 구현하기, 사용자 비밀번호 암호화하여 서버로 넘기기 본문

Project_HYEONGARL

Project_11_Spring Security 사용해서 사용자 인증 구현하기, 사용자 비밀번호 암호화하여 서버로 넘기기

hyeonga 2024. 6. 16. 06:59
반응형

 

 

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

피드백.멘토님께 코드 피드백을 받았다. SpringSecurity와 JWT를 왜 사용했는지 물어보셨다.멘토님께서는 지금까지 실무에서 작업하시면서 Spring Security를 사용한 적이 없다고 하셨다.또한 현재 작업

hyeonga493.tistory.com

 

 

Project_07_Spring Security + JWT 토큰에서 추출한 정보 사용하기

문제. 이전에 작업한 코드들로 서버를 실행하고 postman에서 사용자 생성 > 로그인 > headers에 authorization 추가 > url 등록 시 @AuthenticationPrincipal 을 사용하여 userId를 추출하는 방법을 적용하려고 하였

hyeonga493.tistory.com

 

 

Project_06_SpringSecurity + JWT 인증 후 다른 기능 구현 테스트 코드 작성

Project_03_Spring Security, JWT 구현Project_04_Spring Web LayerSpring Web Layer1. Web Layer뷰 템플릿 영역입니다.( @Controller, JSP, Freemarker ) 외부 요청과 응답에 대한 전반적인 영역입니다.( @Filter, 인터셉터, @Controller

hyeonga493.tistory.com

 

시작. 

바로 앞 전에 Spring Security와 JWT를 들어내고 PasswordEncoder를 직접 설정하여 작업하는 코드로 수정했었다.
멘토님께서 Spring Security를 모두 들어내는 것보다 Spring Security 의 BCryptPasswordEncoder만 사용하는 방법도 있다고 하셔서 코드를 변경하기로 결정했다.

SpringSecurity를 적용하고 BCryptPasswordEncoder를 사용한다(JWT 사용 X).

토큰은 그대로 UUID를 사용한다(+날짜: 중복을 최소한으로 하기 위함).

인증 시 Authentication에 사용자 정보 대신 token 값을 넣는다.

userId는 SecurityContext에서 Authentication에 접근하여 token 으로 Token 테이블에 접근하여 값을 가져온다.


추가.

 사용자가 비밀번호를 입력하고 requestDto로 넘어오는 과정에서 password가 text로 들어오는게 문제가 될 수 있다고 하셔서 어떻게 처리하는 것이 좋은가에 대한 고민을 했다.

앞단에서 입력한 비밀번호를 해싱하여 request로 넘어오고 해싱된 값을 BCryptPasswordEncoder로 암호화 하는 구조로 작업을 수정했다.

 

>> PostMan에서 실행하는 경우 Script의 Pre-req 에 스크립트를 작성하면 서버로 넘어가기 전 데이터를 처리할 수 있다.

const CryptoJS2 = require('crypto-js');

// 입력한 비밀번호
const requestBody = pm.request.body.raw;
const parseBody = JSON.parse(requestBody);

// 비밀번호를 해싱 (여기서는 SHA-256 사용)
const hashedPassword = CryptoJS2.SHA256(parseBody.password).toString(CryptoJS2.enc.Hex);

// 해싱된 비밀번호를 body에 적용
pm.request.body = JSON.stringify({
    "userEmail": parseBody.userEmail,
    "password": hashedPassword
});

// Content-Type 헤더 설정
pm.request.headers.add({
    key: 'Content-Type',
    value: 'application/json'
});

 

 

 

build.gradle

> 작성  코드

	// Spring Security
	implementation 'org.springframework.boot:spring-boot-starter-security'..



> 전체 코드

더보기
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'
	/*
        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'
        	보안과 관련된 기능을 제공하는 스프링 보안 모듈에 대한 스타터 의존성
        	인증(Authentication)과 권한 부여(Authorization)을 위한 기능을 제공
        	웹 애플리케이션의 보안을 강화하는데 사용
    */
}

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

tasks.withType(Test).configureEach {
	enabled = false
}

jar {
	enabled = false
}

 


ApiFilter

> 작성  코드

        Token validToken = tokenRepository.findByTokenAndExpiryDate(token, LocalDateTime.now())
                .orElseThrow(UserUnInvalidException::new);

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                validToken.getToken(), null, new ArrayList<>()
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

 

 

> 전체 코드

더보기
package com.hyeongarl.config;

import com.hyeongarl.entity.Token;
import com.hyeongarl.error.UserUnInvalidException;
import com.hyeongarl.repository.TokenRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;

@Slf4j
@Component
@RequiredArgsConstructor
public class ApiFilter extends OncePerRequestFilter {

    private final TokenRepository tokenRepository;

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

        String requestURI = request.getRequestURI();
        if(requestURI.startsWith("/login") || requestURI.startsWith("/user")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 확인
        String token = request.getHeader("token");
        if(token == null) {
            throw new UserUnInvalidException();
        }

        Token validToken = tokenRepository.findByTokenAndExpiryDate(token, LocalDateTime.now())
                .orElseThrow(UserUnInvalidException::new);

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                validToken.getToken(), null, new ArrayList<>()
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}

 

 

 

TokenService

> 전체 코드

더보기
package com.hyeongarl.service;

import com.hyeongarl.config.Logger;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

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

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

        // 비밀번호 불일치
        if(!bCryptPasswordEncoder.matches(userRequest.getPassword(),(user.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();
    }

    public Long getUserId() {
        Logger.servicelogging("getUserId");
        UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        String token = authentication.getPrincipal().toString();
        log.info("token : {}", token);
        Long userId = tokenRepository.findUserIDByToken(token);
        log.info("userId : {}", userId);

        return userId;
    }
}

 

 

 


TokenRepository

> 전체 코드

더보기
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;
import java.util.Optional;

@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);

    // 유효한 토큰 확인
    @Query("SELECT t " +
            "FROM Token t " +
            "WHERE t.token = :token " +
            "AND t.expiryDate > :now")
    Optional<Token> findByTokenAndExpiryDate(String token, LocalDateTime now);

    @Query("SELECT t.userId " +
            "FROM Token t " +
            "WHERE t.token = :token")
    Long findUserIDByToken(String token);
}

 

 


SecurityConfig

> 전체 코드

더보기
package com.hyeongarl.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final ApiFilter apiFilter;

    @Bean
    BCryptPasswordEncoder passwordEncoder() {
        Logger.logging("Security Config : passwordEncoder()");
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        Logger.logging("Security Config : filterChain()");
        return httpSecurity
                .authorizeHttpRequests(
                        // 특정 경로에 대한 엑세스 설정
                        auth -> auth.requestMatchers("/login", "/user").permitAll()
                                .anyRequest().authenticated()
                )
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(apiFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public ObjectMapper objectMapper() {
        Logger.logging("ApiConfig : objectMapper()");
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return mapper;
    }
}

 

 

 


UserService

> 전체 코드

더보기
package com.hyeongarl.service;

import com.hyeongarl.entity.User;
import com.hyeongarl.error.UserAlreadyExistException;
import com.hyeongarl.error.UserNotFoundException;
import com.hyeongarl.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

/*
    @Service
        Spring Framework에 의해 관리되는 서비스 클래스임을 명시
    @RequiredArgsConstructor
        롬복 라이브러리에서 final 필드에 대한 생성자를 자동으로 생성
 */
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public User save(User user) {
        // 이미 존재하는 이메일인 경우
        if(userRepository.existsByUserEmail(user.getUserEmail())) {
            throw new UserAlreadyExistException();
        }

        return userRepository.save(User.builder()
                .userEmail(user.getUserEmail())
                .password(bCryptPasswordEncoder.encode(user.getPassword()))
                .build());
    }

    // userId로 유저 검색
    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(UserNotFoundException::new);
    }

    // userEmail로 유저 검색
    public User findByUserEmail(String userEmail) {
        return userRepository.findByUserEmail(userEmail)
                .orElseThrow(UserNotFoundException::new);
    }
}

 

 

Logger

로그를 확인하기 쉽게하기 위해 임의로 작성

> 전체 코드

더보기
package com.hyeongarl.config;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Logger {
    public static void logging(String message) {
        log.info("=============== {} ===============", message);
    }
    public static void servicelogging(String message) {
        log.info("----- {} -----", message);
    }
}

 

반응형