다음 페이지에서 jwt 설정 및 배포 처리를 진행 하였다.
이어지는 내용이다.
https://macaronics.net/m01/spring/view/2180.
■ 환경
이 프로젝트는 (세션방식과 + JW T 방식) 두가지를 복합적으로 적용한 프로젝트이다.
- spring boot 3.2.3
- JPA, gradle
- h2, mariadb , redis
- 프론트엔드 : thymelef, react 18.2
■ 소스
https://github.com/braverokmc79/spring-boot-react-jwt-redis
코드 보기
스프링부트 && 리액트
https://github.dev/braverokmc79/spring-boot-react-jwt-redis/tree/main/back-end
jwt 설정 프로젝트 진행한 코드 목록
◆ 작업순서
1. redis 설치
2. 벡인드 JWT access/refresh token 인증 구현
3 리액트 프론트 엔드 구현
◆ 인증 순서
1. 프론트엔드에서 로그인 요청
2.id/pw 검증 후 access/refresh token 발급
3. refresh token은 redis에 저장하고, 프론트엔드로 access/refresh 토큰 응답처리.
4. 프로트엔드에서는 로컬스토리지에 access/refresh token 저장.
5. 필터를 통해 access token으로만 통신을 하다가 access token 만료되면 프론트엔드에 만료 메시지를 전송.
6. 프론트엔드에서는 access token 만료메시지를 응답받고 로컬스토리지 저장된 refresh token을 벡엔드에 전송함.
7. 벡인드에서는 refresh token 에서 id 값을 추출후 redis 에 저장된 refresh token 과 비교하여 값이 일히차면 재발급(reissue) ( access /rfresh token 재발급) 처리
1. redis 설치
0. 우분트22 - Docker 설치방법 https://haengsin.tistory.com/128 1. 도커 redis 데이터 외부 저장경로 생성 $ mkdir /docker $ mkdir /docker/redis/ $ mkdir /docker/redis/data 2. 도커 redis 설치 및 실행 #1)윈도우 docker run -v c:/docker/redis/data:/data --name my-redis -p 6379:6379 redis redis-server --appendonly yes --requirepass test1234 #2)리눅스 docker run -v /docker/redis/data:/data --name my-redis -p 6379:6379 redis redis-server --appendonly yes --requirepass test1234 #docker inspect [container-name] 명령어로 확인하면, 볼륨 마운트 설정확인 $ docker inspect my-redis 3. container 접속 및 테스트 $ docker exec -it my-redis redis-cli #비밀번호 인증 안된 상태에서 redis 명령어 "keys * "를 실해하면 NOAUTH 에러 발생 한다. # 비밀번호 인증 $ auth test1234 # 정상작동 확인 $ set a bbbb $ get a 4.컨테이너를 중지후 재시작 데이터 보존되는 것을 확인 $ docker stop my-redis $ docker start my-redis $ docker exec -it my-redis redis-cli $ auth test1234 $ get a 5.비밀번호 업데이트 ==> 그러나 도커를 정지하고 재구동 하면 원래비밀번호로 변경된다. 따라서 컨테이너 삭제후 docker run 을 통해 다시 컨테이너를 실행해야 한다. 127.0.0.1:6379> config set requirepass 1234 종료후 재 접속 확인 $exit $ docker exec -it my-redis redis-cli $ auth 1234 $ get a
#redis 전체 데이터 보기 127.0.0.1:6379> keys * 1) "refreshToken" 2) "logoutAccessToken:3" 3) "refreshToken:3" 4) "logoutAccessToken" #redis 전체 삭제 : flushall #redis 상세보기 get , hash 타입은 hgetall 127.0.0.1:6379> hgetall refreshToken:3 hgetall logoutAccessToken:3
2. JWT refresh token 인증 구현
1) build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis' //jwt version: '0.12.4' implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
2) application.properties
Redis 연결 풀 설정은 애플리케이션의 요구 사항과 사용 패턴에 따라 달라집니다. 그러나 일반적인 가이드라인은 다음과 같습니다.
max-active: 이 값은 동시에 활성화될 수 있는 최대 연결 수를 설정합니다. 이 값은 애플리케이션의 동시 요청 수를 고려하여 설정해야 합니다. 너무 낮게 설정하면 연결이 부족할 수 있고, 너무 높게 설정하면 불필요한 리소스를 사용하게 될 수 있습니다. 일반적으로는 동시에 처리할 수 있는 최대 요청 수를 고려하여 설정합니다.
max-idle: 이 값은 연결 풀에서 유지할 수 있는 최대 유휴 연결 수를 설정합니다. 이 값은 일반적으로 max-active 값의 25-50% 범위에서 설정합니다. 이렇게 하면 피크 시간 동안에도 충분한 연결을 유지할 수 있습니다.
min-idle: 이 값은 연결 풀에서 유지할 수 있는 최소 유휴 연결 수를 설정합니다. 이 값은 일반적으로 max-idle 값의 10-20% 범위에서 설정합니다. 이렇게 하면 비피크 시간 동안에도 연결이 유지됩니다.
#레디스 설정 # Lettuce는 Redis 클라이언트 라이브러리로, 이 설정은 Lettuce를 사용하여 Redis 연결 풀을 구성합니다. # 최대 활성 연결 수를 50으로 설정합니다. spring.data.redis.lettuce.pool.max-active=50 # 연결 풀에서 유지할 수 있는 최대 유휴 연결 수를 25으로 설정합니다. spring.data.redis.lettuce.pool.max-idle=25 # 연결 풀에서 유지할 수 있는 최소 유휴 연결 수를 5로 설정합니다. spring.data.redis.lettuce.pool.min-idle=5 # Redis 서버의 호스트 이름을 설정합니다. spring.data.redis.host=localhost # Redis 서버의 포트 번호를 설정합니다. spring.data.redis.port=6379 # Redis 서버에 연결할 때 사용할 비밀번호를 설정합니다. spring.data.redis.password=test1234 # JWT 토큰의 만료 시간을 밀리초 단위로 설정합니다. 여기서는 600000밀리초, 즉 10분으로 설정되어 있습니다. jwt.expiredMs=600000 #접근토큰시간: 5시간 #60*60*5*1000 =18000000 #샘플테스트 :30초 ==>30*1000=30000 spring.jwt.token.access-expiration-time=60000 #갱신토큰시간: 7일 #60*60*24*7*1000=604800000 #샘플테스트: 1분 ===>60000 spring.jwt.token.refresh-expiration-time=120000
application-dev.properties
#애플리케이션 포트 설정 server.port = 5000 spring.output.ansi.enabled=always #레디스 설정 # Lettuce는 Redis 클라이언트 라이브러리로, 이 설정은 Lettuce를 사용하여 Redis 연결 풀을 구성합니다. # 최대 활성 연결 수를 50으로 설정합니다. spring.data.redis.lettuce.pool.max-active=50 # 연결 풀에서 유지할 수 있는 최대 유휴 연결 수를 25으로 설정합니다. spring.data.redis.lettuce.pool.max-idle=25 # 연결 풀에서 유지할 수 있는 최소 유휴 연결 수를 5로 설정합니다. spring.data.redis.lettuce.pool.min-idle=5 # Redis 서버의 호스트 이름을 설정합니다. spring.data.redis.host=localhost # Redis 서버의 포트 번호를 설정합니다. spring.data.redis.port=6379 # Redis 서버에 연결할 때 사용할 비밀번호를 설정합니다. spring.data.redis.password=test1234 # JWT 토큰의 만료 시간을 밀리초 단위로 설정합니다. 여기서는 600000밀리초, 즉 10분으로 설정되어 있습니다. jwt.expiredMs=600000 #접근토큰시간: 5시간 #60*60*5*1000 =18000000 #샘플테스트 :30초 ==>30*1000=30000 spring.jwt.token.access-expiration-time=18000000 #갱신토큰시간: 7일 #60*60*24*7*1000=604800000 #샘플테스트: 1분 ===>60000 spring.jwt.token.refresh-expiration-time=120000 #MariaDB server spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.datasource.url=jdbc:mariadb://localhost:3306/shop?serverTimezone=UTF-8&allowPublicKeyRetrieval=true&useSSL=false spring.datasource.username=shop spring.datasource.password=1111 spring.jpa.open-in-view=false #실행되는 쿼리 콘솔 출력 spring.jpa.properties.hibernate.show_sql=true #콘솔창에 출력되는 쿼리를 가독성이 좋게 포맷팅 spring.jpa.properties.hibernate.format_sql=true #쿼리에 물음표로 출력되는 바인드 파라미터 출력 logging.level.org.hibernate.type.descriptor.sql=trace #create , update spring.jpa.hibernate.ddl-auto=update #spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect #spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect #Live Reload 기능 활성화 spring.devtools.livereload.enabled=true #Thymeleaf cache 사용 중지 spring.thymeleaf.cache = false #파일 한 개당 최대 사이즈 spring.servlet.multipart.maxFileSize=20MB #요청당 최대 파일 크기 spring.servlet.multipart.maxRequestSize=100MB #상품 이미지 업로드 경로 itemImgLocation=E:/uploads/shop/item #리소스 업로드 경로 uploadPath=file:///E:/uploads/shop/ #CORS preflight 요청의 결과를 캐시하는 시간을 설정 CORS_MAX_AGE_SECS=3600 #기본 batch size 설정 spring.jpa.properties.hibernate.default_batch_fetch_size=1000
기본적으로 스프링 시큐리티를 설정하면 다음과 같이 UserDetails 설정하도록 한다.
1) PrincipalDetails
import com.shop.constant.Role; import com.shop.entity.Member; import lombok.Getter; import lombok.extern.log4j.Log4j2; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.io.Serial; import java.util.*; import java.util.stream.Collectors; //시큐리티가 login 주소 요청이 오면 낚아채서 로그인을 진행시킨다 //로그인을 진행이 완료가 되면 시큐리티 session 을 만들어 줍니다. (security ContectHolder) 세션 정보 저장 //오브젝트 => Authentication 타입 객체 //Authentication 안에 User 정보가 있어야 함. //User 오브젝트타입 => UserDetails 타입 객체 //Security Session => Authentication => UserDetails @Getter @Log4j2 public class PrincipalDetails implements UserDetails { @Serial private static final long serialVersionUID = 1L; private final Member member; // 콤포지션 private final long id; private final String idStr; //아이디를 문자열로 반환 private final String email; private final String username; /** 다음은 OAuth2User 를 위한 필드 */ private String authProviderId; private Collection<? extends GrantedAuthority> authorities; private Map<String, Object> attributes; // public PrincipalDetails(Member member, Map<String, Object> attributes){ // this.authProviderId=member.getAuthProviderId(); // this.attributes=attributes; // this.authorities= Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); // // this.id=member.getId(); // this.idStr=member.getId().toString(); // this.username=member.getUsername(); // this.email = member.getEmail(); // this.member = member; // } // // // 일반로그인 public PrincipalDetails(Member member) { this.id=member.getId(); this.idStr=member.getId().toString(); this.username=member.getUsername(); this.email = member.getEmail(); this.member = member; this.authorities=getAuthorities(); } // //OAuth 로그인 // public PrincipalDetails(UserVO user, Map<String, Object> attributes) { // this.user=user; // this.attributes=attributes; // } // /** * 사용자에게 부여된 권한을 반환합니다. null을 반환할 수 없습니다. */ // 해당 User 의 권한을 리턴하는 곳!! //권한:한개가 아닐 수 있음.(3개 이상의 권한) @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collector=new ArrayList<>(); // collector.add(new GrantedAuthority() { // // @Override // public String getAuthority() { // return member.getRole(); // } // }); log.info("********* 시큐리티 로그인 :" +member.getRole().toString()); collector.add(()-> member.getRole().toString()); return collector; } /** * 사용자를 인증하는 데 사용된 암호를 반환합니다. */ @Override public String getPassword() { return member.getPassword(); } /** * 사용자를 인증하는 데 사용된 사용자 이름을 반환합니다. null을 반환할 수 없습니다. */ @Override public String getUsername() { return member.getUsername(); } /** * 사용자의 계정이 만료되었는지 여부를 나타냅니다. 만료된 계정은 인증할 수 없습니다. */ @Override public boolean isAccountNonExpired() { return true; } /** * 사용자가 잠겨 있는지 또는 잠금 해제되어 있는지 나타냅니다. 잠긴 사용자는 인증할 수 없습니다. */ @Override public boolean isAccountNonLocked() { return true; } /** * 사용자의 자격 증명(암호)이 만료되었는지 여부를 나타냅니다. 만료된 자격 증명은 인증을 방지합니다. */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 사용자가 활성화되었는지 비활성화되었는지 여부를 나타냅니다. 비활성화된 사용자는 인증할 수 없습니다. */ @Override public boolean isEnabled() { // 우리 사이트 1년동안 회원이 로그인을 안하면!! 휴먼 계정으로 하기로 함. // 현재시간-로긴시간=>1년을 초과하면 return false; return true; } public boolean isWriteEnabled() { if (member.getRole().equals(Role.USER)) return false; else return true; } public boolean isWriteAdminAndManagerEnabled() { if (member.getRole().equals(Role.ADMIN )|| member.getRole().equals(Role.USER)) return true; else return false; } /** * 다음 OAuth2User 를 위한 메소드 * @return */ // @Override // public Map<String, Object> getAttributes() { // return this.attributes; // } // // @Override // public String getName() { // return this.authProviderId; //name 대신 id를 리턴한다. // } }
2)PrincipalDetailsService
import com.shop.entity.Member; import com.shop.repository.MemberRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; 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; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor @Service @Slf4j @Transactional public class PrincipalDetailsService implements UserDetailsService { private final MemberRepository memberRepository; /* 1.패스워드는 알아서 체킹하니깐 신경쓸 필요 없다 2.리턴이 잘되면 자동으로 User 세션을 만든다. */ @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Member member = memberRepository.findByEmail(email); if(member==null) throw new UsernameNotFoundException(email); return new PrincipalDetails(member); } //API 및 Oauth2 로그인시 memberId 로 public UserDetails loadUserApiByUsername(long memberId) throws UsernameNotFoundException { Member member = memberRepository.findById(memberId).orElse(null); if(member==null) throw new UsernameNotFoundException(String.valueOf(memberId)); return new PrincipalDetails(member); } }
① redis 설정
3) RedisConfiguration
package com.shop.config.redis; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import org.springframework.data.redis.serializer.StringRedisSerializer; /** * RedisRepository 를 사용하기 위해 @EnabledRedisRepositories 어노테이션을 붙여준다. */ @Configuration @EnableRedisRepositories public class RedisConfiguration { @Value("${spring.data.redis.host}") private String redisHost; @Value("${spring.data.redis.port}") private int redisPort; @Value("${spring.data.redis.password}") private String redisPassword; @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); //비밀번호 설정시 config.setPassword(redisPassword); return new LettuceConnectionFactory(config); } @Bean public RedisTemplate<String, String> redisTemplate() { RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer()); return redisTemplate; } @Bean public StringRedisTemplate stringRedisTemplate() { StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); stringRedisTemplate.setConnectionFactory(redisConnectionFactory()); return stringRedisTemplate; } }
② redis Entity 생성 , redis repositoy 생성 , TonkenDto 생성
4)RefreshToken
여기서 주의할 점은 @Id 어노테이션입니다.
java.persistence.id가 아닌 org.springframework.data.annotation.Id 를 import 해야 됩니다.
Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다. (persistence로 하면 에러납니다.)
또한 id 변수이름 그대로 사용해야지 CrudRepository 의 findById 를 사용할 수 있다.
import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; /** timeToLive는 유효시간을 값으로 초 단위를 의미합니다. 현재 14_400초 4시간으로 설정 , timeToLive = 14440 */ @Getter @RedisHash(value = "refreshToken") @AllArgsConstructor @Builder @ToString @NoArgsConstructor public class RefreshToken { //여기서 주의할 점은 @Id 어노테이션입니다. //java.persistence.id가 아닌 org.springframework.data.annotation.Id 를 import 해야 됩니다. //Refresh Token은 Redis에 저장하기 때문에 JPA 의존성이 필요하지 않습니다. (persistence로 하면 에러납니다.) // 또한 id 변수이름 그대로 사용해야지 CrudRepository 의 findById 를 사용할 수 있다. @Id private Long id; private String refreshToken; @TimeToLive //기본값 무한 private Long expiration; public static RefreshToken createRefreshToken(Long memberId, String refreshToken, Long remainingMilliSeconds) { return RefreshToken.builder() .refreshToken(refreshToken) .id(memberId) .expiration(remainingMilliSeconds / 1000) .build(); } }
②
5)LogoutAccessToken
import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; import jakarta.persistence.Id; @Getter @RedisHash("logoutAccessToken") @AllArgsConstructor @Builder public class LogoutAccessToken { @Id private Long id; private String refreshToken; //기본값 -1로 Redis 에 영구적으로 유지, 로그아웃 시간 Milliseconds 형식 @TimeToLive private Long logoutTimeMilliseconds; //로그아웃 시간 yyyy-MM-dd HH:mm:ss(EEE) 형식 private String logoutTime; public static LogoutAccessToken of(Long memberId, String refreshToken, Long logoutTimeMilliseconds, String logoutTime) { return LogoutAccessToken.builder() .id(memberId) .refreshToken(refreshToken) .logoutTimeMilliseconds(logoutTimeMilliseconds) .logoutTime(logoutTime) .build(); } }
②
6)TokenDto
import lombok.*; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class TokenDto { private String grantType; private String accessToken; private String refreshToken; public static TokenDto of(String accessToken, String refreshToken) { return TokenDto.builder() .grantType("Bearer ") .accessToken(accessToken) .refreshToken(refreshToken) .build(); } }
MemberDto
package com.shop.dto; import com.shop.constant.Role; import com.shop.dto.api.jwt.TokenDto; import com.shop.entity.Member; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.modelmapper.ModelMapper; @Data @Builder @NoArgsConstructor @AllArgsConstructor public class MemberDto { private String id; private TokenDto token; private String username; private String name; private String email; private Role role; private String authProvider; //이후 OAuth 에서 사용할 유저 정보 제공자 : github private String authProviderId; private static ModelMapper mapper = new ModelMapper(); public static MemberDto of(Member member){ return mapper.map(member, MemberDto.class); } public static Member oauth2CreateMember(MemberDto memberDto){ return mapper.map(memberDto, Member.class); } }
③ JwtTokenUtil, JwtAuthenticationFilter , JwtTokenProviderService
7) JwtTokenUtil
package com.shop.config.jwt; import com.shop.config.auth.PrincipalDetails; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; @Component public class JwtTokenUtil { // 시크릿 키를 담는 변수 private SecretKey cachedSecretKey; /** $ echo '0-p7n#6s4l$ncdoui7+(^q(7^b^tt4@^i@6-q516f=aw-9%@fsdkj52423ksd9fs905235K$!$#49'|base64 => git bash 에서 base64 로 인코딩한 값 private static final String SECRET_KEY = "MC1wN24jNnM0bCRuY2RvdWk3KyhecSg3XmJedHQ0QF5pQDYtcTUxNmY9YXctOSVAZnNka2o1MjQyM2tzZDlmczkwNTIzNUskISQjNDkK"; */ private final String SECRET_KEY= "MC1wN24jNnM0bCRuY2RvdWk3KyhecSg3XmJedHQ0QF5pQDYtcTUxNmY9YXctOSVAZnNka2o1MjQyM2tzZDlmczkwNTIzNUskISQjNDkK"; //1.인증 키생성 public SecretKey getSecretKey() { String keyBase64Encoded = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()); SecretKey secretKey1 = Keys.hmacShaKeyFor(keyBase64Encoded.getBytes()); if (cachedSecretKey == null) cachedSecretKey = secretKey1; return cachedSecretKey; } //2.토큰 정보 파싱 public Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } //3.토큰을 이용하여 유저아이디값 가져오기 반환값 id public String getUsername(String token) { return String.valueOf(extractAllClaims(token).get("memberId")); } /** * 4. 토큰 생성 * @param memberId * @return */ private String doGenerateToken(Long memberId, long expireTime) { Claims claim = Jwts.claims(); claim.put("memberId", memberId); //기한 지금으로부터 1일로 설정 // Date expiryDate =Date.from(Instant.now().plus(1, ChronoUnit.DAYS)); //Date now = new Date(); now.getTime() 으로 하면 오류 Date expireDate = new Date(System.currentTimeMillis() + expireTime); //JWT Token 생성 return Jwts.builder() .setClaims(claim) .signWith(getSecretKey()) //토큰 서명 설정 //payload 에 들어갈 내용 .setSubject(String.valueOf(memberId)) //sub .setIssuer("macaronics app") //iss .setIssuedAt(new Date()) //iat .setExpiration(expireDate) //exp .compact(); //문자열로 압축 } //5.접근 토큰 생성 public String generateAccessToken(Long memberId, long expireTime) { return doGenerateToken(memberId, expireTime); } //6.갱신 토큰 public String generateRefreshToken(Long memberId, long expireTime) { return doGenerateToken(memberId, expireTime); } //7.토큰 만료 여부 public Boolean isTokenExpired(String token) { Date expiration = extractAllClaims(token).getExpiration(); return expiration.before(new Date()); } //8.토큰 유효성 체크 public Boolean validateToken(String token, PrincipalDetails userDetails) { String memberId = getUsername(token); return memberId.equals(String.valueOf(userDetails.getId())) && !isTokenExpired(token); } //9.토큰 남은시간 public long getRemainMilliSeconds(String token) { Date expiration = extractAllClaims(token).getExpiration(); Date now = new Date(); return expiration.getTime() - now.getTime(); } /** * 토큰 확인 * parseClaimsJws 메시드 Base 64로 디코딩 및 파싱. * 즉, 헤더와 페이로드를 setSigningKey 로 넘어온 시크릿을 이용해 서명 후, token 의 서명과 비교. * 위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림 , 그중 우리는 memberId 가 필요하므로 getBody 를 부른다. * @param token * @return */ public String validateAndGetUserId(String token){ Claims claims=Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } }
③
8) JwtAuthenticationFilter
doFilterInternal
a) parseBearerToken 으로 Bearer 제거 후 토큰 가져오기 Bearer
b) 토큰 널값 확인후 접근 토큰 유효성 검사하기,
여기서는 유효성이 안맞는 경우는 무조건 접근 토큰시간 만료라는 메시지를 보내도록 처리 하였다.
c) PrincipalDetails 통해 널 값 확인 체크
d) 최종적으로 정상적인 토큰일경우 에는 authSetConfirm 메서드에서 인증확인된 데이터를
넣어주므로서 시큐리티에 통과하도록 설정한다.
private void authSetConfirm(HttpServletRequest request, PrincipalDetails principalDetails){ //인증 완료; SecurityContextHolder 에 등록해야 인증된 사용자라고 생각한다. AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( principalDetails ,// userId,//인증된 사용자의 정보. 문자열이 아니어도 아무거나 넣을 수 있다. 보통 UserDetails 를 넣는다. null, principalDetails.getAuthorities() //권한 설정값을 넣어 준다. ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 정상 토큰이면 SecurityContext 에 저장 SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); SecurityContextHolder.setContext(securityContext); }
중요) 프론트에서 처리 결과에 대한 값을 던져 주는 방법으로 다음과 같이 처리하였다.
a) 필터 제외 페이지 설정을 shouldNotFilter 을 오버라이딩 으로 해서 코딩했다는 점을 중요하게 살펴 보자.
b)예외 처리를 반환을 catch 를 통해서 진행하였다.
첫번째 catch는 CustomAuthenticationException 일 경우 response.getWriter() 로 반환 처리
그리고 두번째 catch 는 throw 를 던져 주므로서 CustomAuthenticationEntryPoint 에서 에러를 처리하도록 하였다.
package com.shop.config.filter; import com.fasterxml.jackson.databind.ObjectMapper; import com.shop.config.auth.PrincipalDetails; import com.shop.config.auth.PrincipalDetailsService; import com.shop.exception.CustomAuthenticationException; import com.shop.dto.api.todo.ResDTO; import com.shop.service.api.jwt.JwtTokenProviderService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.io.PrintWriter; @Component @RequiredArgsConstructor @Log4j2 public class JwtAuthenticationFilter extends OncePerRequestFilter { private final PrincipalDetailsService principalDetailsService; private final JwtTokenProviderService tokenProvider; //JWT 필터 제외 페이지 설정 private static final String[] excludedUrlPatterns = { "/", "/static/**", "/favicon.ico", "/css/**","/js/**","/images/**", "/main", "/members","/members/**", "/cart", "/cart/**", "/cartItem", "/cartItem/**", "/admin", "/admin/**", "/item", "/item/**", "/order", "/order/**", "/orders","/orders/**", "/thymeleaf/**", "/api/auth/signup","/api/auth/signin" , "/api/auth/reissue", "/api/auth/logout" }; // 필터 제외 페이지 설정 @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return exclusionPages(request); } public static boolean exclusionPages(HttpServletRequest request){ String requestUrl = request.getRequestURI(); for (String pattern : excludedUrlPatterns) { if (!requestUrl.startsWith("/api")) { //api 시작 안되면 통과 //log.info("//api 시작되는 것은 통과 :{}", requestUrl); return true; }else if (pattern.contains("**")) { // URL 패턴에 **이 있으면, ** 앞부분만 비교하여 제외합니다. String patternBeforeDoubleStar = pattern.substring(0, pattern.indexOf("**")); if (requestUrl.startsWith(patternBeforeDoubleStar)) { return true; } } else { // URL 패턴에 **이 없으면 같음을 비교합니다. if (requestUrl.equals(pattern)) { return true; } } } return false; // 제외할 URL 패턴이 없는 경우 false를 반환합니다. } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("========= doFilterInternal getRequestURI : {}", request.getRequestURI()); try{ log.info("Filter running .."); //요청에서 토큰 가져오기 String token=parseBearerToken(request); //토큰 검사하기 . JWT 이므로 인가 서버에 요청하지 않고도 검증 가능. userId 가져오기. 위조된 경우 예외 처리된다. if(token!=null && !token.equalsIgnoreCase("null")){ String memberId =null; try{ memberId = tokenProvider.validateAndGetUserId(token); }catch(Exception e){ //throw new CustomAuthenticationException("접근 토큰시간이 만료되었습니다.", "TOKEN_EXPIRED"); throw new CustomAuthenticationException("접근 토큰시간이 만료되었습니다.","TOKEN_EXPIRED"); } //JWT 토큰로그인 인증에서는 API , oauth2 는 loadUserApiByUsername 커스텀으로 생성한 메서드로 id 값으로 인증처리 PrincipalDetails principalDetails =(PrincipalDetails)principalDetailsService.loadUserApiByUsername(Long.parseLong(memberId)); if(principalDetails!=null){ log.info("필터 ===principalDetails {}", principalDetails.getMember().getRole().toString()); authSetConfirm( request, principalDetails); filterChain.doFilter(request, response); }else throw new CustomAuthenticationException("해당하는 유저가 없습니다.", "USER_NOT_FOUND"); }else throw new CustomAuthenticationException("유효하지 않은 토큰 입니다.","INVALID_TOKEN" ); }catch (CustomAuthenticationException e ){ log.info("JWT CustomAuthenticationException 에러 처리 "); //1.에러처리 방법 :커스텀 에러 메시지는 다음과 같이 printWriter 전송 처리 한다. response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setCharacterEncoding("utf-8"); response.setContentType("application/json"); ResDTO<Object> errorRes = ResDTO.builder().code(-1).message(e.getMessage()).errorCode(e.getErrorCode()).build(); ObjectMapper objectMapper = new ObjectMapper(); String result = objectMapper.writeValueAsString(errorRes); PrintWriter printWriter=response.getWriter(); printWriter.print(result); printWriter.flush(); printWriter.close(); }catch (Exception e){ log.info("JWT 기타 에러 메시지처리 "); //2.에러처리 방법 : 기타 에러 메시지는 다음과 같이 throw new 호출하여 JwtAuthenticationEntryPoint 클래스로 전송 처리 시킨다. request.setAttribute("message","JWT 토큰 필터 처리 에러입니다."); request.setAttribute("errorCode",e.getMessage()); throw new BadCredentialsException("Invalid"); } } private void authSetConfirm(HttpServletRequest request, PrincipalDetails principalDetails){ //인증 완료; SecurityContextHolder 에 등록해야 인증된 사용자라고 생각한다. AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( principalDetails ,// userId,//인증된 사용자의 정보. 문자열이 아니어도 아무거나 넣을 수 있다. 보통 UserDetails 를 넣는다. null, principalDetails.getAuthorities() //권한 설정값을 넣어 준다. ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 정상 토큰이면 SecurityContext 에 저장 SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); SecurityContextHolder.setContext(securityContext); } /** * Http 요청의 헤더를 파싱해 Bearer 토큰을 리턴한다. * @param request * @return */ public static String parseBearerToken(HttpServletRequest request){ String bearerToken = request.getHeader("Authorization"); if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){ return bearerToken.substring(7); } return null; } }
③
9) JwtTokenProviderService
package com.shop.service.api.jwt; import com.shop.exception.CustomAuthenticationException; import com.shop.config.jwt.JwtTokenUtil; import com.shop.dto.MemberDto; import com.shop.dto.api.jwt.TokenDto; import com.shop.entity.Member; import com.shop.entity.api.jwt.LogoutAccessToken; import com.shop.entity.api.jwt.RefreshToken; import com.shop.repository.MemberRepository; import com.shop.repository.api.jwt.LogoutAccessTokenRedisRepository; import com.shop.repository.api.jwt.RefreshTokenRedisRepository; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.NoSuchElementException; /** * 추가된 라이브러리를 사용해서 JWT를 생성하고 검증하는 컴포넌트 */ @Log4j2 @Service @RequiredArgsConstructor @Transactional public class JwtTokenProviderService { private final RefreshTokenRedisRepository refreshTokenRedisRepository; private final MemberRepository memberRepository; private final LogoutAccessTokenRedisRepository logoutAccessTokenRedisRepository; private final JwtTokenUtil jwtTokenUtil; @Value("${spring.jwt.token.access-expiration-time}") private long accessExpirationTime; @Value("${spring.jwt.token.refresh-expiration-time}") private long refreshExpirationTime; /** * 1. 로그인시 접근 토큰 생성 * @param member * @return */ public TokenDto create(Member member){ //1.접근 토큰 생성 String accessToken = jwtTokenUtil.generateAccessToken(member.getId(), accessExpirationTime); //2.갱신토큰 생성후 redis 에 저장 RefreshToken refreshToken = saveRefreshToken(member.getId()); log.info("*********** 접근토큰 : {}" ,accessToken); log.info("*********** 갱신토큰 : {}",refreshToken); return TokenDto.of(accessToken, refreshToken.getRefreshToken()); } //갱신토큰을 redis 에 저장 private RefreshToken saveRefreshToken(Long memberId) { RefreshToken refreshToken = RefreshToken.createRefreshToken(memberId, jwtTokenUtil.generateRefreshToken(memberId, refreshExpirationTime), refreshExpirationTime); refreshTokenRedisRepository.save(refreshToken); return refreshToken; } /** * 2. access token + refresh token 재발급 처리 * @param memberId * @return */ private TokenDto reissueRefreshToken( long memberId) { //access token + refresh token 재발급 String accessToken = jwtTokenUtil.generateAccessToken(memberId, accessExpirationTime); RefreshToken refreshToken = saveRefreshToken(memberId); return TokenDto.of(accessToken, refreshToken.getRefreshToken()); } /** * 3.토큰 재발행 * * * ★ * 현재 이 코드는 , 회원아이디 값을 키값을 설정 했기 때문에, 하나의 브라우저에서만 로그인 된다. * 정확이 말하면, 기존에 접속한 브라우저에서는 accessExpirationTime 만료시까지 유지 된다. * 즉, 새로운 브라우저로 로그인하면서 redis 에서 새로운 refresh token 값을 저장했기 때문이다. * * ★여러 브라우저에서 가능하도록 하려면, refresh token 을 키값을 설정하면 된다. * * * @return */ public MemberDto reissue(String refreshToken ) throws Exception{ //1.refreshToken 를 파싱해서 memberId 값을 가져온다. String memberId=validateAndGetUserId(refreshToken); log.info("1.refreshToken 를 파싱해서 memberId 값을 가져온다. {}",memberId); //2.Redis 저장된 토큰 정보를 가져온다. RefreshToken redisRefreshToken = refreshTokenRedisRepository.findById(memberId).orElseThrow(NoSuchElementException::new); log.info("2.Redis 저장된 토큰 정보를 가져온다. {}", redisRefreshToken.getRefreshToken()); //3.redis 에 저장된 토큰과 값과 파라미터의 갱신토크와 비교해서 같으면 갱신토큰 발급처리 if (refreshToken.equals(redisRefreshToken.getRefreshToken())) { TokenDto tokenDto = reissueRefreshToken( Long.parseLong(memberId)); Member member = memberRepository.findById(Long.parseLong(memberId)).orElse(null); if(member!=null){ MemberDto memberDto = MemberDto.of(member); memberDto.setToken(tokenDto); return memberDto; }throw new CustomAuthenticationException("해당하는 유저가 없습니다.", "USER_NOT_FOUND"); } throw new CustomAuthenticationException("유효하지 않은 토큰 입니다.","INVALID_TOKEN" ); } /** * 토큰 확인 * @param token * @return */ public String validateAndGetUserId(String token) throws Exception{ return jwtTokenUtil.validateAndGetUserId(token); } public void logout(String refreshToken) throws Exception{ //1.refreshToken 를 파싱해서 memberId 값을 가져온다. String memberId=validateAndGetUserId(refreshToken); //2.redis 에서 삭제 처리 refreshTokenRedisRepository.deleteById(memberId); String logOutTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss(EEE)", Locale.ENGLISH)); //3.redis 에 로그아웃 날짜 저장 logoutAccessTokenRedisRepository.save(LogoutAccessToken.of(Long.valueOf(memberId), refreshToken, System.currentTimeMillis(), logOutTime)); } }
④ 예외 처리 , SecurityConfig 설정
10) CustomAuthenticationException
package com.shop.exception; import lombok.*; import org.springframework.security.core.AuthenticationException; @Getter @Setter @AllArgsConstructor @NoArgsConstructor @Builder public class CustomAuthenticationException extends RuntimeException { private String message; private String errorCode; }
11) CustomAuthenticationEntryPoint
package com.shop.exception; import com.fasterxml.jackson.databind.ObjectMapper; import com.shop.config.filter.JwtAuthenticationFilter; import com.shop.dto.api.todo.ResDTO; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.log4j.Log4j2; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.io.IOException; /** * 토큰 필터 에러 처리 */ @Component @Log4j2 public class CustomAuthenticationEntryPoint extends RuntimeException implements AuthenticationEntryPoint { /** * 1.API 에러 설정 * @param request * @param response * @param authException * @throws IOException */ @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { log.info("========= 1.JwtAuthenticationEntryPoint getMessage : {}", authException.getMessage()); log.info("========= 2.JwtAuthenticationEntryPoint getMessage : {}", request.getContentType()); //예외 페이지가 아니고, json 타입이 아닌 경우 세션 페이지로 에러 페이지 이동처리 if (JwtAuthenticationFilter.exclusionPages(request) && StringUtils.hasText(request.getContentType()) && !request.getContentType().equals("application/json")) { response.sendRedirect(sessionErrorPage(authException.getMessage(), response)); return; } String errorCode =(String) request.getAttribute("errorCode"); String message =(String) request.getAttribute("message"); log.info("******* 3.JwtAuthenticationEntryPoint 토큰 필터 에러 처리 : errorCode :{} , message :{} ",errorCode, message); if(StringUtils.hasText(errorCode) || ( StringUtils.hasText(authException.getMessage()) && org.thymeleaf.util.StringUtils.contains(authException.getMessage(),"authentication") )){ response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setCharacterEncoding("utf-8"); response.setContentType("application/json"); ResDTO<Object> errorRes ; if(org.thymeleaf.util.StringUtils.contains(authException.getMessage(),"authentication")){ errorRes=ResDTO.builder().code(-1).message("권한이 없습니다. 해당 리소스에 접근할 수 없습니다.").errorCode("ACCESS_DENIED").build(); //response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); }else{ errorRes=ResDTO.builder().code(-1).message(message).errorCode(errorCode).build(); } ObjectMapper objectMapper = new ObjectMapper(); String result = objectMapper.writeValueAsString(errorRes); response.getWriter().print(result); } } /** * 2.세션 페이지 에러 페이지 설정 * @param message * @param response * @return */ private String sessionErrorPage(String message, HttpServletResponse response){ String erroPage="/error/404"; if(StringUtils.hasText(message)){ if(org.thymeleaf.util.StringUtils.contains(message,"authentication")){ erroPage="/error/404"; }else{ erroPage="/error/500"; } } return erroPage; } }
12) SecurityConfig
package com.shop.config; import com.querydsl.jpa.impl.JPAQueryFactory; import com.shop.exception.CustomAuthenticationEntryPoint; import com.shop.config.filter.JwtAuthenticationFilter; import jakarta.persistence.EntityManager; 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.HeadersConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity @RequiredArgsConstructor //@EnableGlobalMethodSecurity(prePostEnabled = true)//기본값 true 업데이트 됩 public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean public JPAQueryFactory queryFactory(EntityManager em){ return new JPAQueryFactory(em); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //JWT 를 사용하면 csrf 보안이 설정 필요없다. 그러나 여기 프로젝트에서는 세션방식과 jwt 방식을 둘다적용 중이라 특정 페이지만 제외 처리 http.csrf(c -> { c.ignoringRequestMatchers("/admin/**","/api/**", "/oauth2/**" ,"/error/**"); }); //1.csrf 사용하지 않을 경우 다음과 같이 설정 //http.csrf(AbstractHttpConfigurer::disable); //2. h2 접근 및 iframe 접근을 위한 SameOrigin (프레임 허용) & 보안 강화 설정 할경우 http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); // 다른 도메인 간에 프레임(Frame)을 허용하려면 X-Frame-Options 헤더를 비활성화(disable) //http.headers((headers) -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); //3.HTTP 기본 인증 만 할경우 다음과 같이 처리 //httpSecurity.formLogin(AbstractHttpConfigurer::disable); //3.jwt token 만 인증 처리 할경우 basic 인증을 disable 한다. 그러나 이 프로젝트는 세션+jwt 이라 disable 설정은 하지 않는다. //httpSecurity.httpBasic(AbstractHttpConfigurer::disable); //4.JWT 만 사용할경우 세션 관리 상태 없음 설정 그러나, 이 프로젝트는 세션 + JWT 를 사용하지 때문에 주석 //http.sessionManagement(sessionManagementConfigurer ->sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); //세션방식의 form 로그인 처리 http.formLogin(login-> login .loginPage("/members/login") .defaultSuccessUrl("/", true) .usernameParameter("email") .failureUrl("/members/login/error")) .logout(logoutConfig ->logoutConfig.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")).logoutSuccessUrl("/")) .exceptionHandling(exceptionConfig-> exceptionConfig.authenticationEntryPoint(new Http403ForbiddenEntryPoint())); http.authorizeHttpRequests(request->request .requestMatchers("/css/**", "/js/**", "/img/**","/images/**").permitAll() .requestMatchers("/", "/members/**", "/item/**", "/main/**", "/error/**" ).permitAll() //JWT 일반 접속 설정 .requestMatchers("/auth/**").permitAll() .requestMatchers("/api/todo/**" ,"/api/auth/**").permitAll() //JWT 관리자 페이지 설정 .requestMatchers( "/api/admin/**").hasAuthority("ADMIN") //세션방식 --> 관리자 페이지는 설정 .requestMatchers("/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ); //=======api 페이지만 JWT 필터 설정(jwtAuthenticationFilter 에서 shouldNotFilter 메서드로 세션 페이지는 필터를 제외 시켰다.) http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) //에외 처리 .exceptionHandling(exceptionConfig->exceptionConfig.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) ); return http.build(); } }
12-2)WebMvcConfig
package com.shop.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.resource.PathResourceResolver; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${uploadPath}") String uploadPath; /** * MAX_AGE_SECS * CORS 설정에서 maxAge 는 브라우저가 CORS preflight 요청의 결과를 캐시하는 * 시간을 설정하는 값입니다. * 이 값은 서버의 CORS 설정이 변경되지 않을 것으로 예상되는 시간을 기준으로 설정 * 개발 환경에서는 maxAge 를 1시간(3600초)으로 설정하고, * 프로덕션 환경에서는 maxAge 를 24시간(86400초) 또는 그 이상으로 설정 */ @Value("${CORS_MAX_AGE_SECS}") long CORS_MAX_AGE_SECS; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .setCachePeriod(60*10*6) // 1시간 .setCachePeriod(60*10*6) .addResourceLocations(uploadPath); registry .addResourceHandler("/upload/**") //jsp 페이지에서 /upload/** 이런주소 패턴이 나오면 발동 .addResourceLocations(uploadPath+"/upload/") .setCachePeriod(60*10*6) // 1시간 .resourceChain(true) .addResolver(new PathResourceResolver()); } @Override public void addCorsMappings(CorsRegistry registry) { /** ★★★★★ nginx 연동의 경우 모든 경로에 대해 cors 허용 하면 안된다. * 또한, * 현재 프로젝트가 todo 와 api 인데 * restapi 가 아닌 경로까지 적용되면 restapi 아닌 페이지는 오규가 발생한다. /모든 경로에 대해 cors 허용 ex) registry.addMapping("/**") * registry.addMapping("/cart/**"), * registry.addMapping("/members/**") */ //★★★★★ nginx 연동의 경우 루트 /** 으로 설정이 안된다 다음과 같이 개별 설정 ★★★★★ registry.addMapping("/todo/**") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) // 'Access-Control-Allow-Credentials' header 는 요청 시 자격 증명이 필요함 .maxAge(CORS_MAX_AGE_SECS) .allowedOrigins( "http://localhost:3000/" ,"https://ma7front.p-e.kr/" ).exposedHeaders("authorization"); //authorization 헤더를 넘기 위해 exposedHeaders 조건을 추가 registry.addMapping("/auth/**") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) // 'Access-Control-Allow-Credentials' header 는 요청 시 자격 증명이 필요함 .maxAge(CORS_MAX_AGE_SECS) .allowedOrigins( "http://localhost:3000/" ,"https://ma7front.p-e.kr/" ).exposedHeaders("authorization"); //authorization 헤더를 넘기 위해 exposedHeaders 조건을 추가 registry.addMapping("/oauth2/**") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) // 'Access-Control-Allow-Credentials' header 는 요청 시 자격 증명이 필요함 .maxAge(CORS_MAX_AGE_SECS) .allowedOrigins( "http://localhost:3000/" ,"https://ma7front.p-e.kr/" ).exposedHeaders("authorization"); //authorization 헤더를 넘기 위해 exposedHeaders 조건을 추가 registry.addMapping("/api/**") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS").allowedHeaders("*") .allowCredentials(true) // 'Access-Control-Allow-Credentials' header 는 요청 시 자격 증명이 필요함 .maxAge(CORS_MAX_AGE_SECS) .allowedOrigins( "http://localhost:3000/" ,"https://ma7front.p-e.kr/" ).exposedHeaders("authorization"); //authorization 헤더를 넘기 위해 exposedHeaders 조건을 추가 } }
13) ApiMemberController
package com.shop.controller.api; import com.shop.dto.MemberDto; import com.shop.dto.MemberFormDto; import com.shop.dto.api.jwt.TokenDto; import com.shop.dto.api.todo.ResDTO; import com.shop.entity.Member; import com.shop.service.MemberService; import com.shop.service.api.jwt.JwtTokenProviderService; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @Log4j2 public class ApiMemberController { private final MemberService memberService; private final PasswordEncoder passwordEncoder; private final JwtTokenProviderService tokenProvider; /** * API 회원 가입 처리 * @param memberFormDto * @return */ @PostMapping("/signup") public ResponseEntity<?> registerUser(@RequestBody MemberFormDto memberFormDto){ try{ if(memberFormDto==null || memberFormDto.getPassword()==null){ throw new RuntimeException("비밀번호가 유효하지 않습니다."); } //1.요청을 이용해 저장할 유저 만들기 memberFormDto.setPassword(passwordEncoder.encode(memberFormDto.getPassword())); Member member = Member.createMember(memberFormDto); //2.서비스를 이용해 리포지터리에 유저 저장 MemberDto memberDto = MemberDto.of(memberService.createMember(member)); return ResponseEntity.ok(memberDto); }catch (Exception e){ return ResponseEntity.badRequest().body(ResDTO.builder().code(-1).errorCode(e.getMessage()).build()); } } /** * 로그인 * @param memberFormDto * @return */ @PostMapping("/signin") public ResponseEntity<?> authenticate(@RequestBody MemberFormDto memberFormDto){ Member memberEntity = memberService.getMemberUsername(memberFormDto.getUsername()); if(memberEntity!=null && passwordEncoder.matches(memberFormDto.getPassword(), memberEntity.getPassword())){ log.info("3. signIn================>{}" ,memberFormDto.getUsername()); //토큰 생성 final TokenDto tokenDto = tokenProvider.create(memberEntity); MemberDto memberDto = MemberDto.of(memberEntity); memberDto.setToken(tokenDto); return ResponseEntity.ok(memberDto); }else{ return ResponseEntity.badRequest().body(ResDTO.builder().code(-1).message("아이디 또는 비밀번호가 일치하지 않습니다.").errorCode("not match").build()); } } /**확인 사항 * 1.갱신토큰값 과 재발행 토큰값과 비교시 갱신토큰값이 작으면 접근토큰 및 갱신토큰 발행 * 2. * 갱신토큰 재발행 * @param refreshToken * @return */ @PostMapping("/reissue") public ResponseEntity<?> reissue(@RequestHeader(value = "RefreshToken") String refreshToken) { log.info("갱신 토큰 발행 =======================>"); try{ return ResponseEntity.ok(tokenProvider.reissue(refreshToken)); }catch (Exception e){ //갱신토큰이 유요하지 않을 경우 code 값을 -1로 주고, 프론트에서 "refreshToken is invalid" 메시지 확인후 로그아웃 처리한다. return ResponseEntity.badRequest().body(ResDTO.builder().code(-1).message("갱신 토큰이 유요하지 않습니다.").errorCode("INVALID_REFRESH_TOKEN").build()); } } /** * 로그 아웃 처리 * redis 에서 저장된 memberId + refresh token 삭제 처리 한다. * */ @PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader(value = "RefreshToken") String refreshToken) { log.info("로그 아웃 처리 =======================>"); try { tokenProvider.logout(refreshToken); return ResponseEntity.ok(ResDTO.builder().code(1).message("success").build()); }catch (Exception e) { return ResponseEntity.ok(ResDTO.builder().code(-1).message("로그아웃 처리 오류").errorCode(e.getMessage()).build()); } } }
3. React 프론트 엔드 구현
1)api-config.js
let backendHost=""; const hostname= window && window.location &&window.location.hostname; if(hostname ==="localhost"){ backendHost="http://localhost:8080"; }else{ backendHost="http://localhost:5050"; } export const API_BASE_URL =backendHost;
2)ApiServie.js
Promise-값이 반화 처리되는데,
다음 사이트를 참조해서 살펴 보도록 한다.
https://squirmm.tistory.com/entry/ReactJavaScript-Promise-값-가져오기
//https://squirmm.tistory.com/entry/ReactJavaScript-Promise-값-가져오기 //promiseresult 데이터값 반환 async function getData(resData){ return await resData.then((promiseResult)=>{ return promiseResult; }); }
ApiService.jsx
import { API_BASE_URL } from "../api-config"; //1.공통 처리 noThen : true 일경우 then 처리 생략 export function callApi(url, method, request, noThen){ let headers=new Headers({'Content-Type': 'application/json'}) ; //2.로컬 스토리지에서 ACCESS TOKEN 가져오기 const accessToken =localStorage.getItem('ACCESS_TOKEN'); if(accessToken && accessToken!=null){ headers.append('Authorization', 'Bearer '+ accessToken); } //3.갱신토큰 재발급 처리 + 로그아웃시 갱신 토큰 확인처리 (갱신) if(request&& request.REFRESH){ const refreshToken =localStorage.getItem('REFRESH_TOKEN'); headers.append('RefreshToken',refreshToken); } let options={ headers:headers, url:API_BASE_URL+url, method:method } if(request){ options.body=JSON.stringify(request); } async function fethData(){ let resData=[]; try { if(noThen){ //1)로그인, 2)회원가입, 3)접근토큰 재발급일 경우 다음을 실행 return await fetch(options.url, options); }else{ const res= await fetch(options.url, options); resData= await res.json(); if(res.status !== 200){ console.log("resData===",resData); if(resData&& resData.errorCode==="TOKEN_EXPIRED"){ //접근토큰 만료 오류일경우 갱신처리 if(await reissue()){ //true 이면 call 함수를 재호출한다. return await callApi(url, method, request); } } sinOut(); return; } } } catch (error) { console.log("fethData 오류 : ", error); } return resData; } return fethData(); } //2.로그인 처리 export async function signIn(userDTO){ return callApi("/api/auth/signin", "POST", userDTO, true) .then(async res=>{ if(await lsSave(res)){ window.location.href="/"; }else{ alert("아이디 또는 비밀번호가 일치하지 않습니다."); } }); } //3.회원 가입 처리 export async function signUp(userData){ const res= await callApi("/api/auth/signup", "POST", userData, true); if(res.status===200){ const jd=await res.json(); if(jd&&jd.code===1){ alert(jd.message); return true; } }else{ const jd=await res.json(); //console.log("jd " ,jd); if(jd.errorCode){ alert(jd.errorCode); return false; } } } //4.로그 아웃처리 export async function sinOut(){ try{ const logout=await callApi("/api/auth/logout", "POST", {REFRESH:true}); console.log("logout " ,logout); }catch(error){ console.log("로그아웃 오류 :", error); } lsRemove(); window.location.href="/login"; } //5.토큰 재발급 처리 async function reissue(){ const res= await callApi("/api/auth/reissue", "POST", {REFRESH:true}, true); if(lsSave(res)){ alert("*토큰 재발급 처리*"); return true; }else{ alert("*토큰 재발급 처리 오류 *"); } sinOut(); } //6.로컬스토리지 저장 async function lsSave(res){ try{ console.log("==lsSave :",res); const jd= await res.json(); console.log("==jd :",jd); if(res.status===200){ const accessToken= jd&& jd.data&& jd.data.token&&jd.data.token.accessToken; const refreshToken= jd&& jd.data&& jd.data.token&&jd.data.token.refreshToken; if(accessToken&&refreshToken) { localStorage.setItem("ACCESS_TOKEN", accessToken); localStorage.setItem("REFRESH_TOKEN", refreshToken); return true; } }else{ if(jd.errorCode==="INVALID_REFRESH_TOKEN"){ //갱신토큰 오류 로그 아웃처리 alert(jd.message); sinOut(); } return false; } }catch(error){ console.log("lsSave 오류 :", error); sinOut(); } } //7.로컬스토리지 데이터 삭제 function lsRemove(){ localStorage.removeItem("ACCESS_TOKEN"); localStorage.removeItem("REFRESH_TOKEN"); return true; } //json 반환 처리 데이터 예 /** { "code": 1, "message": "success", "errorCode": null, "data": { "id": "1", "token": { "grantType": "Bearer ", "accessToken": "1234토큰", "refreshToken": "" }, "username": "test1", "name": "테스터1", "email": "test1@gmail.com", "role": "USER", "authProvider": null, "authProviderId": null } } */
3)App.js
import "./App.css"; import Todo from "./components/Todo"; import { useEffect, useState } from "react"; import { AppBar, Button, Container, Grid, List, Paper, Toolbar, Typography } from "@mui/material"; import AddTodo from "./components/AddTodo"; import { callApi, sinOut } from "./config/ApiService"; function App() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); //목록 불러오기 useEffect(() => { async function getItemsList(){ try{ await callApi("/api/todo", "get" , null).then(res=> { console.log("getItemsList : ", res); if(res.code===-1)window.location.href="/login"; setItems(res.data) }); setLoading(false); }catch(error){ console.log(error); } } getItemsList(); }, []) //등록처리 const addItem=(item)=>{ callApi("/api/todo", "POST" , item).then(res=> setItems(res.data)); } //삭제처리 const deleteItem=(item)=>{ callApi("/api/todo", "DELETE" , item).then(res=> setItems(res.data)); } //수정하기 const editItem=(item)=>{ callApi("/api/todo", "PUT" , item).then(res=> setItems(res.data)); } //navigationBar 추가 let navigationBar =( <AppBar postion="static" > <Toolbar> <Grid justifyContent="space-between" container> <Grid item> <Typography variant="h6">오늘의 할일</Typography> </Grid> <Grid item> <Button color="inherit" onClick={sinOut}> 로그아웃 </Button> </Grid> </Grid> </Toolbar> </AppBar> ); let todoItems=items&&items!==null && items.length > 0 && ( <Paper className="mt50"> <List> {items.map((item)=>( <Todo getItem={item} key={item.todoId} editItem={editItem} deleteItem={deleteItem} /> )) } </List> </Paper> ) // 로딩중이 아닐 때 랜더링할 부분 let todoListPage=( <> {navigationBar} <Container maxWidth="md" className="to70"> <AddTodo addItem={addItem} /> <div className="TodoList">{todoItems}</div> </Container> </> ) ///로딩중리 때 래더링할 부분 let loadingPage=<h1>로딩중....</h1> let content=loadingPage; if(!loading){ // 로딩중이 아니라면 todoListPage 를 선택 content=todoListPage; } return ( <div className="App"> {content} </div> ); } export default App;
댓글 ( 0)
댓글 남기기