hyeonga_code
Project_11_Spring Security 사용해서 사용자 인증 구현하기, 사용자 비밀번호 암호화하여 서버로 넘기기 본문
Project_11_Spring Security 사용해서 사용자 인증 구현하기, 사용자 비밀번호 암호화하여 서버로 넘기기
hyeonga 2024. 6. 16. 06:59
시작.
바로 앞 전에 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);
}
}
'Project_HYEONGARL' 카테고리의 다른 글
Project_13_카테고리 기능 구현 Cache 적용하기 (0) | 2024.06.21 |
---|---|
Project_12_카테고리 기능 구현 (0) | 2024.06.18 |
Spring Security (0) | 2024.06.16 |
Project_Docker DockerCompose (0) | 2024.06.12 |
Project_10_SpringSecurity, JWT 없이 사용자 인증 구현하기(토큰) (1) | 2024.06.08 |