우선 회원가입보다 로그인기능을 먼저 작업할 생각이다..
전체 구조
1. Frontend - React
- 로그인페이지 : 일반로그인 및 소셜 로그인 버튼을 제공함
- API 요청 : 사용자가 로그인 할 때, 서버에 요청을 보냄
- JWT 관리 : 서버에서 발급받은 JWT를 localStorage또는 sessionStorage에 저장함
2. Backend - Spring Boot
- 일반 로그인 처리 : 사용자로부터 받은 비밀번호를 암호화(BCrypt)로 해싱하여 DB에 저장하고, 로그인시 해당 해시와 사용자가 입력한 비밀번호 비교함
- 소셜 로그인 : OAuth2를 통해 카카오와 네이버 로그인 기능 처리하고 로그인후 JWT발급
ㄴ 일단 소셜로그인은 일반로그인 기능 다 끝내고 할생각입니다.(생각보다어려워서..)
- JWT : JWT 생성하고 클라이언트에서 요청 시 해당 JWT 유효성을 검사함
구현 방법
의존성 추가
- build.gradle에 [Spring Security, JWT] 추가
- 의존성 추가후 gradle refresh 필수..
// build.gradle
ependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8'
/* Spring Security */
implementation 'org.springframework.boot:spring-boot-starter-security'
/* OAuth2 클라이언트 */
// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
/* JWT */
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
SecurityConfig
package dev.momory.moneymindbackend.config;
import dev.momory.moneymindbackend.jwt.JWTFilter;
import dev.momory.moneymindbackend.jwt.JWTUtil;
import dev.momory.moneymindbackend.jwt.LoginFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.CorsConfigurer;
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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import java.util.Collections;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private AuthenticationConfiguration authenticationConfiguration;
private JWTUtil jwtUtil;
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {
this.authenticationConfiguration = authenticationConfiguration;
this.jwtUtil = jwtUtil;
}
/**
* AuthenticationManager를 Bean으로 등록하여 스프링 컨텍스트에서 사용 가능하게 합니다.
* AuthenticationManager은 사용자 인증을 관리하는 주요 인터페이스
*/
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
/**
* BCryptPasswordEncoder를 Bean으로 등록
* BCryptPasswordEncoder는 비밀번호를 해싱하는데 사용하며, 보안성이 높은 알고리즘 제공
* @return
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* SecurityFilterChain은 Bean으로 등록하여 HTTP 보안 설정을 정의
* CORS, CSRF 보호, 인증 및 권한 부여, 세션관리 정의
* @param http
* @return
* @throws Exception
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// cors설정
http.cors(cors -> customCorsConfiguration(cors));
// CORS 보호 기능을 비활성화
http.csrf(csrf -> csrf.disable());
// 기본적으로 제공되는 로그인 폼 기능 비활성화
http.formLogin(form -> form.disable());
// HTTP 기본 인증 비활성화
http.httpBasic(auth -> auth.disable());
// 요청에 대한 접근 제어 규칙 정의
http.authorizeHttpRequests(auth -> auth
// "/", "/login" 인증 없이 접근 허용
.requestMatchers("/", "/api/login", "/api/reissue").permitAll()
// 나머지 모든 요청에 대해서는 인증을 요구함
.anyRequest().authenticated());
// JWT 인증 필터를 LoginFilter 앞에 추가합니다.
http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
// 로그인 필터를 UsernamePasswordAuthenticationFilter 자리에 추가합니다.
http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil)
, UsernamePasswordAuthenticationFilter.class);
// 세션 관리 정책을 설정
// 서버가 세션을 생성하거나 유지하지 않도록 합니다.
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
/**
* CORS 설정 : 다른 도메인에서의 요청 허용하기위한 설정
* @param cors
* @return
* @throws Exception
*/
public CorsConfiguration customCorsConfiguration(CorsConfigurer<HttpSecurity> cors) {
cors.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
// CORS 정책을 정의하는 객체 생성
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 허용할 출처 : 로컬, 개발서버
corsConfiguration.setAllowedOrigins(Collections.singletonList("http://localhost:5173"));
// 허용할 HTTP 메서드 설정(모든 메서드 허용)
corsConfiguration.setAllowedMethods(Collections.singletonList("*"));
// 인증 정보를 요청 헤더에 포함할 수 있도록 허용
corsConfiguration.setAllowCredentials(true);
// 클라이언트가 보낼 수 있는 헤더의 목록을 지정(모든 헤더 허용)
corsConfiguration.setAllowedHeaders(Collections.singletonList("*"));
// 브라우저가 CORS 결과를 캐시할 시간(3600초)
corsConfiguration.setMaxAge(3600L);
// 클라이언트가 접근할 수 있는 응답 헤더 지정
corsConfiguration.setExposedHeaders(Collections.singletonList("Authorization"));
return corsConfiguration;
}
});
return new CorsConfiguration();
}
}
CorsMvcConfig
package dev.momory.moneymindbackend.config;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* CorsMvcConfig 클래스는 Spring MVC의 전역 CORS 설정을 정의합니다.
* WebMvcCOnfigurer 인터페이스를 구현하여 CORS 관련 설정을 커스터마이징 합니다.
*/
public class CorsMvcConfig implements WebMvcConfigurer {
/**
* addCorsMappings 메서드는 CORS 매핑을 추가합니다.
* 이 메서드를 통해 특정 경로에 대한 CORS 허용 정책을 설정할 수 있습니다.
* @param registry CORS 설정을 등록할 수 있는 레지스트리 객체
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 모든 경로 데해 CORS를 허용합니다.
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173");
}
}
CustomUserDetails
package dev.momory.moneymindbackend.dto;
import dev.momory.moneymindbackend.entity.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.*;
/**
* CustomUserDetails 클래스는 Spring Security의 UserDetail 인터페이스를 구현하여
* 사용자 정보 커스터마이징 하여 제공
* 사용자 엔티티(User)를 기반으로 정보를 반환
*/
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetails implements UserDetails {
private final User user;
/**
* 사용자 권한 정보 반환
* @return 빈배열(현재 프로젝트에서는 권한 사용하지 않음)
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
/**
* 사용자의 비밀번호를 반환합니다.
* @return User 엔티티에 저장된 암호하된 비밀번호
*/
@Override
public String getPassword() {
return user.getPassword();
}
/**
* 사용자 이름(실명)을 반환
* @return User 엔티티에 저장된 사용자 이름
*/
@Override
public String getUsername() {
return user.getUserid();
}
/**
* 사용자 계정(로그인) 반환
* @return User 엔티티에 저장된 사용자 계정
*/
public String getUserid() {
return user.getUserid();
}
/**
* 사용자의 계정이 만료되지 않았는지 반환
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 사용자의 계정이 잠기지 않았는지를 반환
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 사용자의 자격 증명이 만료되지 않았는지를 반환
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 사용자의 계정이 활성화되어 있는지를 반환
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
TokenCategory
package dev.momory.moneymindbackend.dto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* JWT 토큰 유형을 관리하는 enum 클래스
*/
@Getter
@RequiredArgsConstructor
public enum TokenCategory {
/**
* ACCESS : 엑세스 토큰을 나타내는 상수
* REFRESH : 리프레시 토큰을 나타내는 상수
*/
ACCESS("access"),
REFRESH("refresh");
private final String value;
}
LoginFilter
package dev.momory.moneymindbackend.jwt;
import dev.momory.moneymindbackend.dto.CustomUserDetails;
import dev.momory.moneymindbackend.dto.TokenCategory;
import dev.momory.moneymindbackend.util.CookieUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
/**
* LoginFilter 클래스는 사용자의 로그인 요청을 처리하고,
* 인증이 성공적으로 완료된 후 JWT 토큰을 생성하여 응답(header)에 추가함
*/
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
// 로그인 URL 변경
setFilterProcessesUrl("/api/login");
// 로그인 계정 파라미터 변경
setUsernameParameter("userid");
}
/**
* 사용자의 로그인 요청을 처리합니다.
* @param request HTTP 요청
* @param response HTTP 응답
* @return Authentication 객체
* @throws AuthenticationException 인증 실패 시 예외
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 요청에서 사용자아이디, 비밀번호 추출
String userid = obtainUsername(request);
String password = obtainPassword(request);
log.info("userid = {}", userid);
// 사용자 이름과 비밀번호를 포함한 인증 토큰 생성
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userid, password);
// AuthenticationManager를 통해 인증 시도
return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
}
/**
* 인증이 성공적으로 완료된 후 호출됨
* JWT 토큰을 생성하여 응답 헤더에 추가합니다.
* @param request HTTP 요청
* @param response HTTP 응답
* @param chain 필터 체인
* @param authentication 인증 정보
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
log.info("successful authentication");
// 인증된 사용자 정보를 CustomUserDetails에서 가져옵니다.
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
// 사용자 아이디 추출
String userid = customUserDetails.getUserid();
// JWT 토큰 생성
String access = jwtUtil.createJwt(TokenCategory.ACCESS, userid, 600000L);
String refresh = jwtUtil.createJwt(TokenCategory.REFRESH, userid, 86400000L);
// 응답 설정
response.setHeader(TokenCategory.ACCESS.getValue(), access);
response.addCookie(CookieUtil.createCookie(TokenCategory.REFRESH.getValue(), refresh));
response.setStatus(HttpServletResponse.SC_OK);
}
/**
* 인증이 실패한 경우 호출
* @param request HTTP 요청
* @param response HTTP 응답
* @param failed 인증 실패 예외
* @throws IOException 입출력 예외
* @throws ServletException 서블릿 예외
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("LoginFilter.unsuccessfulAuthentication");
// 응답 코드 401 설정
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
CustomUserDetailsService
package dev.momory.moneymindbackend.service;
import dev.momory.moneymindbackend.dto.CustomUserDetails;
import dev.momory.moneymindbackend.entity.User;
import dev.momory.moneymindbackend.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String userid) throws UsernameNotFoundException {
log.info("CustomUserDetailsService loadUserByUserid = {}", userid);
// 로그인 userid 존재 하는지 확인
// Boolean isExist = userRepository.existsByUserid(userid);
// log.info("isExist = {}", isExist);
User user = userRepository.findByUserid(userid);
log.info("userid={}", user.getUserid());
log.info("email={}", user.getEmail());
log.info("password={}", user.getPassword());
log.info("username={}", user.getUsername());
log.info("provider={}", user.getAuthProvider().name());
if (user != null) {
return new CustomUserDetails(user);
}
return null;
}
}
JWTUtil
package dev.momory.moneymindbackend.jwt;
import dev.momory.moneymindbackend.dto.TokenCategory;
import dev.momory.moneymindbackend.entity.AuthProvider;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Date;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* JWTUtil 클래스는 JWT 토큰을 생성, 파싱 및 검증하는 데 사용되는 유틸리티 클래스
*/
@Component
public class JWTUtil {
// 대칭 키 암호화를 위한 SecretKey 객체, JWT 서명 및 검증에 사용됨
private SecretKey secretKey;
public JWTUtil(@Value("${jwt.secretKey}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(UTF_8),
Jwts.SIG.HS256.key().build().getAlgorithm());
}
/**
* JWT 토큰에서 사용자아이디(계정)을 추출
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰에서 추출한 사용자 아이디
*/
public String getUserid(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("userid", String.class);
}
/**
* JWT 토큰에서 사용자이름(실명)을 추출
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰에서 추출한 사용자 이름(실명)
*/
public String getUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("username", String.class);
}
/**
* JWT 토큰에서 로그인타입을 추출
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰에서 추출한 로그인타입
*/
public AuthProvider getAuthProvider(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("authProvider", AuthProvider.class);
}
/**
* JWT 토큰에서 이메일을 추출
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰에서 추출한 이메일
*/
public String getEmail(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("email", String.class);
}
/**
* JWT 토큰의 만료 여부를 확인
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰이 만료되었으면 true, 그렇지 않으면 false를 반환
*/
public Boolean isExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
/**
* JWT 토큰에서 유형 구분하여 추출
* @param token 사용자 인증에 사용되는 JWT토큰
* @return 토큰유형 추출
*/
public String getCategory(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("category", String.class);
}
/**
* 새로운 JWT 토큰을 생성
* @param tokenCategory 토큰 카테고리(refresh/access)
* @param userid 사용자 아이디
* @param expiredMs 토큰의 만료 시간
* @return 생성된 JWT 토큰 문자열
*/
public String createJwt(TokenCategory tokenCategory, String userid, Long expiredMs) {
return Jwts.builder()
.claim("category", tokenCategory.getValue())
.claim("userid", userid)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
JWTFilter
package dev.momory.moneymindbackend.jwt;
import dev.momory.moneymindbackend.dto.CustomUserDetails;
import dev.momory.moneymindbackend.dto.TokenCategory;
import dev.momory.moneymindbackend.entity.AuthProvider;
import dev.momory.moneymindbackend.entity.User;
import io.jsonwebtoken.ExpiredJwtException;
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.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
/**
* JWTFilter 클래스는 HTTP요청에서 JWT 토큰을 검증하고,
* 인증 정보를 SecurityContext에 설정하는 필터 클래스 입니다.
* Spring Security의 OncePerRequestFilter를 확장하여 요청당 한번만 실행됨
*/
@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
/**
* HTTP 요청을 필터링하고 JWT 토큰을 검증합니다.
* @param request HTTP 요청
* @param response HTTP 응답
* @param filterChain 필터 체인
* @throws ServletException 서블릿 예외
* @throws IOException 입출력 예외
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더에서 "access" 토큰 추출
String accessToken = request.getHeader(TokenCategory.ACCESS.getValue());
// access 토큰이 없을 경우, 다음 필터로 이동하여 요청을 처리
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
try {
// JWT 토큰 만료 여부 확인
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
// 토큰이 만료된 경우, 응답에 만료 메세지 작성
PrintWriter writer = response.getWriter();
writer.print("access token expired");
// HTTP 응답 상태를 401로 설정(Unauthorized)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// JWT 토큰에서 "category" 값을 추출
String category = jwtUtil.getCategory(accessToken);
// "category" 값이 "access"인지 체크
if (!TokenCategory.ACCESS.getValue().equals(category)) {
PrintWriter writer = response.getWriter();
writer.print("access denied");
// HTTP 응답 상태를 401로 설정(Unauthorized)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰에서 사용자 정보를 추출
String userid = jwtUtil.getUserid(accessToken);
String username = jwtUtil.getUsername(accessToken);
String email = jwtUtil.getUsername(accessToken);
AuthProvider authProvider = jwtUtil.getAuthProvider(accessToken);
// 사용자 정보를 담은 User 객체 생성
User user = new User();
user.setUserid(userid);
user.setUsername(username);
user.setEmail(email);
user.setAuthProvider(authProvider);
// CustomUserDetails 객체를 생성후 사용자 세부 설정
CustomUserDetails customUserDetails = new CustomUserDetails(user);
// UsernamePasswordAuthenticationToken 객체를 생성하여 인증 정보 생성
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, null);
// SecurityContext 인증 정보를 설정
SecurityContextHolder.getContext().setAuthentication(authToken);
// 필터 체인을 계속 진행
filterChain.doFilter(request, response);
}
}
JWTAuthController
package dev.momory.moneymindbackend.controller;
import dev.momory.moneymindbackend.dto.TokenCategory;
import dev.momory.moneymindbackend.service.JWTAuthService;
import dev.momory.moneymindbackend.util.CookieUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* JWT 인증 및 토큰 재발급을 담당하는 컨트롤러
* 클라이언트로부터 요청을 받아 JWT 토큰을 검증하고, 필요한 경우 새로운 토큰을 발급합니다.
*/
@RestController
@RequiredArgsConstructor
public class JWTAuthController {
private final JWTAuthService jwtAuthService;
/**
* 클라이언트로부터의 요청에서 리프레시 토큰을 추출하고,
* 토큰을 검증하여 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.
* @param request HTTP 요청 객체
* @param response HTTP 응답 객체
* @return HTTP 상태 코드와 함께 요청 처리 결과를 포함한 ResponseEntity
*/
@PostMapping("/api/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
// 요청에서 리프레시 토큰을 쿠키에서 추출
String refresh = CookieUtil.getCookieValue(request.getCookies());
try {
// JWTAuthService를 사용하여 리프레시 토큰 검증 및 새 토큰 발급
String[] tokens = jwtAuthService.validateAndReissueTokens(refresh).split(":");
// 응답 헤더에 새로운 액세스 토큰 설정
response.setHeader(TokenCategory.ACCESS.getValue(), tokens[0]);
// 응답에 새로운 리프레시 토큰을 쿠키로 추가
response.addCookie(CookieUtil.createCookie(TokenCategory.REFRESH.getValue(), tokens[1]));
// 응답 성공
return new ResponseEntity<>(HttpStatus.OK);
} catch (IllegalArgumentException | IllegalStateException e) {
// 토큰 검증 또는 재발급 중 발생한 예외에 대해 BAD_REQUEST 응답 반환
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
}
JWTAuthService
package dev.momory.moneymindbackend.service;
import dev.momory.moneymindbackend.dto.TokenCategory;
import dev.momory.moneymindbackend.jwt.JWTUtil;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* JWT 토큰의 유효성을 검증하고, 만료된 경우 새로 발급하는 서비스 클래스
* 이 클래슨느 JWTUtil을 사용하여 토큰의 유효성을 검사하고 새로운 액세스 및 리프레시 토큰을 생성합니다.
*/
@Service
@RequiredArgsConstructor
public class JWTAuthService {
private final JWTUtil jwtUtil;
/**
* 리프레시 토큰을 검증하고, 유효할 경우 새로운 액세스 및 리프레시 토큰을 생성하여 반환합니다.
*@param refresh 리프레시 토큰
*@return 새로운 액세스 토큰과 리프레시 토큰을 콜론(:)으로 구분한 문자열
*@throws IllegalArgumentException 리프레시 토큰이 null이거나 유효하지 않은 경우
*@throws IllegalStateException 리프레시 토큰이 만료된 경우
*/
public String validateAndReissueTokens(String refresh) {
// refresh 토큰이 null인지 확인
if (refresh == null) {
throw new IllegalArgumentException("Refresh token is null");
}
try {
//JWTUtil을 사용하여 리프레시 토큰이 만료되었는지 확인
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
// 리프레시 토큰이 만료된 경우 예외 발생
throw new IllegalStateException("Refresh token expired");
}
// 토큰의 카테고리를 가져옴
String category = jwtUtil.getCategory(refresh);
// 카테고리가 "refresh"인지 확인
if (!TokenCategory.REFRESH.getValue().equals(category)) {
throw new IllegalArgumentException("Invalid refresh token");
}
// 리프레시 토큰에서 사용자 ID 추출
String userid = jwtUtil.getUserid(refresh);
// 새로운 액세스 토큰과 리프레시 토큰을 생성
String newAccess = jwtUtil.createJwt(TokenCategory.ACCESS, userid, 600000L);
String newRefresh = jwtUtil.createJwt(TokenCategory.REFRESH, userid, 86400000L);
// 새로운 액세스 토큰과 리프레시 토큰을 콜론으로 구분하여 반환
return newAccess + ":" + newRefresh;
}
}
CookieUtil
package dev.momory.moneymindbackend.util;
import dev.momory.moneymindbackend.dto.TokenCategory;
import jakarta.servlet.http.Cookie;
/**
* 쿠키 생성과 관련된 유틸리티 클래스
*/
public class CookieUtil {
/**
* 주어진 키(key)와 값(value)으로 새로운 쿠키를 생성하는 메서드.
* 이 메서드는 HTTP 응답에 설정할 쿠키를 만들고, 기본적인 속성들을 설정합니다.
* @param key 쿠키의 이름
* @param value 쿠키의 값
* @return 설정된 속성을 가진 새로운 쿠키 객체
*/
public static Cookie createCookie(String key, String value) {
// 주어진 키와 값으로 새로운 쿠키 객체 생성
Cookie cookie = new Cookie(key, value);
// 쿠키의 유효 기간을 설정(단위:초)
// 현재 설정된 값은 24시간(24시간 * 60분 * 60초)
cookie.setMaxAge(24 * 60 * 60);
// 쿠키가 HTTPS 연결에서만 전송되도록 설정
// cookie.setSecure(true);
// 쿠키의 유효 경로 설정
// cookie.setPath("/");
// 쿠키를 HttpOnly 속성으로 설정
// JavaScript에서 쿠키에 접근하지 못하도록 방지하여 보안을 강화하는 설정
cookie.setHttpOnly(true);
return cookie;
}
/**
* 전달받은 쿠키 배열에서 특정 이름의 쿠키 값을 찾아 반환
* @param cookies 쿠키 배열
* @return 쿠키 배열에서 찾은 특정 이름의 쿠키 값 반환,
* 해당 이름의 쿠키가 없거나 배열이 null일 경우 null 반환
*/
public static String getCookieValue(Cookie[] cookies) {
// 쿠키 배열이 null인 경우, 배열이 전달되지 않았거나 비어있는 경우에는 null을 반환
if (cookies == null) {
return null;
}
// 쿠키 배열을 순회하여 각 쿠키를 확인
for (Cookie cookie : cookies) {
// 쿠키이름이 refresh인지 체크
if (TokenCategory.REFRESH.getValue().equals(cookie.getName())) {
// 쿠키의 이름이 일치하면 해당 쿠키의 값을 반환합니다.
return cookie.getValue();
}
}
// 지정된 이름의 쿠키가 배열에 없으면 null을 반환합니다.
return null;
}
}
테스트
User 테이블 저장된 값
ID : user1
PW : user1
로그인 - /api/login - POST
- postman 으로 로그인후 refreshToken(cookie), accessToken(Header) 응답받는지 확인
- * 지금현재 formData로만 받을수있으며, 추후에는 json으로 변경예정(일단생각만..ㅎㅎ)
토큰 재발급 - /api/reissue - POST
- refreshToken이 Cookie에저장되어있어야지만 accessToken 재발급 됨
요청 Headers에 Cookie - refresh 쿠키가 있어야함 .. 필수!
원래 기존에는 단일 Token으로만 작업할려고했는데.. 생각보다 귀찮기도해서..
보안에도좋고 많이사용하는 accessToken, refreshToken 으로 소스를 수정하였다.
생각보다 기존 방식에서 별로 달라진게 많이 없기도하고
Token 한번더 복습한다 생각하고 진행하였다.
'프로그래밍 > 개인프로젝트' 카테고리의 다른 글
[프로젝트] 7. 오류페이지(일반) - 프론트화면(react) (0) | 2024.09.04 |
---|---|
[프로젝트] 6. 로그인(일반) - 프론트화면(react) (1) | 2024.09.03 |
[프로젝트] 4. 프로젝트 Entity (0) | 2024.08.29 |
[프로젝트] 3. 프로젝트 폴더 구조 (3) | 2024.08.28 |
[프로젝트] 2. ERD 및 테이블 정의서 작성하기 (1) | 2024.08.28 |