Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

10주차 산출물(Weekly -> Develop) #201

Merged
merged 29 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d911ac5
Fix, Refactor : refreshToken 만료 시 InvalidJwtException 반환하도록 변경, acces…
2iedo Nov 1, 2024
ab36b30
Feat: helloCallDetailResponse에 ServiceTime 필드 추가 (프론트 요청) (#143)
GitJIHO Nov 2, 2024
a700120
Refactor: 카카오 DTO 4차 코드리뷰 반영 (#141)
GitJIHO Nov 3, 2024
2a965e7
Feat : 각 도메인별 테스트 코드 추가 및 수정 및 각 멤버 별(보호자, 시니또) 할 수 있는 행동 제약 추가 (#131)
2iedo Nov 3, 2024
b32d48a
Refactor: 포인트 도메인 운영코드 개선과 테스트코드 개선 및 추가 (#144)
zzoe2346 Nov 3, 2024
57cc407
Deploy: 무중단 배포 구현 중 health check를 위한 dependency 추가 (#146)
GitJIHO Nov 3, 2024
1c1fcac
Fix: 배포 과정중 dependency 에러처리 (#148)
GitJIHO Nov 3, 2024
b31f10b
Test: 무중단배포 테스트를 위한 pr (#150)
GitJIHO Nov 3, 2024
2cbe7e1
HotFix: 토큰 보안로직 추가 구현 (#152)
GitJIHO Nov 4, 2024
fe139cd
HotFix: swagger server 환경따라 변경 및 도메인 상수화 구현 (#154)
GitJIHO Nov 4, 2024
41e0a54
Refactor: 포인트관련 로직을 포인트 서비스계층으로 통합, 개별 트랜잭션 적용, 테스트 코드 개선 (#132)
zzoe2346 Nov 4, 2024
ddcf568
Deploy: 프론트 배포주소 변경에 따른 상수화값 변경 (#157)
GitJIHO Nov 4, 2024
d1fce24
hotfix : Exception 변경 (#160)
2iedo Nov 5, 2024
2a9fb26
Feat: 카카오메시지 전송 프로토타입 생성 완료 (#161)
GitJIHO Nov 5, 2024
28d8040
HotFix: 카카오메시지 형식 수정 완료 (#164)
GitJIHO Nov 5, 2024
d0d6329
HotFix : refresh Token 1분 이내에 발급되었을 경우 다시 발급되지 않게 수정 (#168)
2iedo Nov 5, 2024
6e35123
feat, hotfix : 회원탈퇴 api 추가 및 redisTemplate 수정 (#170)
2iedo Nov 5, 2024
ac992c0
Deploy: DB mysql로 마이그레이션 구현 (#171)
GitJIHO Nov 6, 2024
88e2f95
HotFix: 테스트 ddl 설정 변경 (#174)
GitJIHO Nov 6, 2024
1f20e6b
feat, test : redis 전체 삭제 기능 구현 및 테스트 코드 추가 (#175)
2iedo Nov 6, 2024
c6a0c0d
Fix: 환불하는데 돈이 오히려 차감되던 버그 수정과 시니어 등록, 수정 시 폰 번호 중복 확인 로직 추가 (#166)
zzoe2346 Nov 6, 2024
40f045c
HotFix: 로그아웃 시 멤버가 없으면 로그아웃 안되는 에러 해결 (#178)
GitJIHO Nov 6, 2024
9295e8b
Feat: 시니또 탈퇴시 본인이 할당받은 서비스가 있으면 다시 대기상태로 전환되는 로직 추가, SET_NULL 설정 (#181)
zzoe2346 Nov 7, 2024
06a6e43
Refactor: 5차 코드리뷰 기반 개편 (안부전화 요일 변환, URL 환경변수화 관련) 구현 (#185)
GitJIHO Nov 7, 2024
da72e89
HotFix: 시니어 정보 수정 시 전화번호를 수정하지 않고 다른 필드를 수정하면 전화번호 중복 오류가 뜨는 문제 해결 (#…
GitJIHO Nov 7, 2024
bb19e7c
HotFix: 슬랙 웹 훅 URL 환경변수화 과정에서 오류 해결 (#188)
GitJIHO Nov 8, 2024
fb4565c
Deploy: 충전 및 출금 요청 발생시 슬랙메시지 전송 기능 추가 (#190)
GitJIHO Nov 8, 2024
3fa6f2e
HotFix: 속성 Value를 항상 null로 받는 문제 해결 (#192)
GitJIHO Nov 8, 2024
9b41fec
HotFix: 중복 환경변수 이분화 (#197)
GitJIHO Nov 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ jobs:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- name: Run deployment script on server
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.SERVER_IP }} 'bash /home/ubuntu/deploy.sh'
ssh -o StrictHostKeyChecking=no ubuntu@${{ secrets.SERVER_IP }} "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }} bash /home/ubuntu/deploy.sh"
21 changes: 21 additions & 0 deletions .github/workflows/testcode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
mysql:
image: mysql:8.0
ports:
- 3306:3306
env:
MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }}
MYSQL_USER: ${{ secrets.MYSQL_USERNAME }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
options: >-
--health-cmd "mysqladmin ping --silent"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout code
Expand All @@ -32,5 +46,12 @@ jobs:

- name: Run Tests
env:
SPRING_DATASOURCE_URL: ${{ secrets.MYSQL_URL }}
SPRING_DATASOURCE_USERNAME: ${{ secrets.MYSQL_USERNAME }}
SPRING_DATASOURCE_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
SPRING_JPA_HIBERNATE_DDL_AUTO: "update"
JWT_SECRET: ${{ secrets.JWT_SECRET }}
SLACK_NOTICE_WEBHOOK_URL: ${{ secrets.SLACK_NOTICE_WEBHOOK_URL }}
SLACK_CHARGE_REQUEST_URL: ${{ secrets.SLACK_CHARGE_REQUEST_URL }}
SLACK_WITHDRAW_REQUEST_URL: ${{ secrets.SLACK_WITHDRAW_REQUEST_URL }}
run: ./gradlew test
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0'
runtimeOnly 'com.h2database:h2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
implementation group: 'com.twilio.sdk', name: 'twilio', version: '10.5.0'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'ch.qos.logback:logback-classic:1.4.12'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'mysql:mysql-connector-java:8.0.33'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,10 @@ public ResponseEntity<LoginResponse> kakaoCallback(@RequestParam("code") String
return ResponseEntity.ok().body(loginResponse);
}

@Operation(summary = "Redis안의 모든 데이터 제거", description = "발급된 refreshToken을 사용하지 못하게 Redis 안의 모든 데이터를 제거합니다.")
@DeleteMapping("/redis")
public ResponseEntity<Void> deleteAllDataFromRedis(){
tokenService.deleteAllDataFromRedis();
return new ResponseEntity<>(HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public record KakaoTokenResponse(
String accessToken,
String refreshToken,
@NotNull
@Positive
int expiresIn,
@NotNull
@Positive
int refreshTokenExpiresIn

) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public class KakaoApiService {

private static final String KAKAO_AUTH_BASE_URL = "https://kauth.kakao.com/oauth";
private static final String KAKAO_API_BASE_URL = "https://kapi.kakao.com/v2/user";
private static final String LOCALHOST_URL = "localhost:5173";

private final RestTemplate restTemplate;
private final KakaoProperties kakaoProperties;

Expand All @@ -34,9 +36,9 @@ public String getAuthorizationUrl(HttpServletRequest httpServletRequest) {
}
String redirectUri;

if (requestUrl.contains("localhost:5173")) {
if (requestUrl.contains(LOCALHOST_URL)) {
redirectUri = kakaoProperties.devRedirectUri();
} else if (requestUrl.contains("sinitto.s3-website.ap-northeast-2.amazonaws.com")) {
} else if (requestUrl.contains(kakaoProperties.frontUriWithoutHttps())) {
redirectUri = kakaoProperties.redirectUri();
} else {
throw new BadRequestException("해당 도메인에서는 카카오 로그인이 불가합니다. requestUrl : " + requestUrl);
Expand All @@ -57,9 +59,9 @@ public KakaoTokenResponse getAccessToken(String authorizationCode, HttpServletRe
}
String redirectUri;

if (requestUrl.contains("localhost:5173")) {
if (requestUrl.contains(LOCALHOST_URL)) {
redirectUri = kakaoProperties.devRedirectUri();
} else if (requestUrl.contains("sinitto.s3-website.ap-northeast-2.amazonaws.com")) {
} else if (requestUrl.contains(kakaoProperties.frontUriWithoutHttps())) {
redirectUri = kakaoProperties.redirectUri();
} else {
throw new BadRequestException("해당 도메인에서는 카카오 로그인이 불가합니다. requestUrl : " + requestUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import com.example.sinitto.auth.dto.KakaoTokenResponse;
import com.example.sinitto.auth.entity.KakaoToken;
import com.example.sinitto.auth.repository.KakaoTokenRepository;
import com.example.sinitto.common.exception.NotFoundException;
import com.example.sinitto.common.exception.UnauthorizedException;
import com.example.sinitto.common.exception.InvalidJwtException;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;

Expand Down Expand Up @@ -36,11 +35,15 @@ public void saveKakaoToken(String email, KakaoTokenResponse kakaoTokenResponse)
@Transactional
public String getValidAccessTokenInServer(String email) {
KakaoToken kakaoToken = kakaoTokenRepository.findByMemberEmail(email)
.orElseThrow(() -> new NotFoundException("email에 해당하는 카카오 토큰이 없습니다."));
.orElse(null);

if (kakaoToken == null) {
return null;
}

if (kakaoToken.isAccessTokenExpired()) {
if (kakaoToken.isRefreshTokenExpired()) {
throw new UnauthorizedException("카카오 리프레쉬 토큰이 만료되었습니다. 카카오 재 로그인 필요");
throw new InvalidJwtException("카카오 리프레쉬 토큰이 만료되었습니다. 카카오 재 로그인 필요");
}
KakaoTokenResponse kakaoTokenResponse = kakaoApiService.refreshAccessToken(kakaoToken.getRefreshToken());
kakaoToken.updateKakaoToken(kakaoTokenResponse.accessToken(), kakaoTokenResponse.refreshToken(), kakaoTokenResponse.expiresIn(), kakaoTokenResponse.refreshTokenExpiresIn());
Expand Down
73 changes: 53 additions & 20 deletions src/main/java/com/example/sinitto/auth/service/TokenService.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package com.example.sinitto.auth.service;

import com.example.sinitto.auth.dto.TokenResponse;
import com.example.sinitto.common.exception.AccessTokenExpiredException;
import com.example.sinitto.common.exception.InvalidJwtException;
import com.example.sinitto.common.exception.RefreshTokenStolenException;
import com.example.sinitto.common.exception.*;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.DataType;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

Expand All @@ -20,13 +19,14 @@
@Service
public class TokenService {

private static final long ACCESS_TEN_HOURS = 1000 * 60 * 60 * 10;
private static final long ACCESS_FIVE_MINUTES = 1000 * 60 * 5;

private static final long REFRESH_SEVEN_DAYS = 1000 * 60 * 60 * 24 * 7;

private final Key secretKey;
private final RedisTemplate<String, String> redisTemplate;
private final RedisTemplate<String, Object> redisTemplate;

public TokenService(@Value("${jwt.secret}") String secretKey, RedisTemplate<String, String> redisTemplate) {
public TokenService(@Value("${jwt.secret}") String secretKey, RedisTemplate<String, Object> redisTemplate) {
byte[] decodedKey = Base64.getDecoder().decode(secretKey);
this.secretKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA256");
this.redisTemplate = redisTemplate;
Expand All @@ -35,48 +35,74 @@ public TokenService(@Value("${jwt.secret}") String secretKey, RedisTemplate<Stri
public String generateAccessToken(String email) {
return Jwts.builder()
.setSubject(email)
.claim("tokenType", "access")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TEN_HOURS))
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_FIVE_MINUTES))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}

public String generateRefreshToken(String email) {
DataType keyType = redisTemplate.type(email);
if (keyType != null && keyType != DataType.HASH) {
redisTemplate.delete(email);
}
String refreshToken = Jwts.builder()
.setSubject(email)
.claim("tokenType", "refresh")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_SEVEN_DAYS))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();

redisTemplate.opsForValue().set(email, refreshToken, REFRESH_SEVEN_DAYS, TimeUnit.MILLISECONDS);
redisTemplate.opsForHash().put(email, "refreshToken", refreshToken);
redisTemplate.opsForHash().put(email, "createdAt", System.currentTimeMillis());

redisTemplate.expire(email, REFRESH_SEVEN_DAYS, TimeUnit.MILLISECONDS);
return refreshToken;
}


public String extractEmail(String token) {
Claims claims;
public String extractEmailFromAccessToken(String accessToken) {
Claims claims = parseClaims(accessToken);
if (!"access".equals(claims.get("tokenType", String.class))) {
throw new BadRequestException("사용된 토큰이 엑세스 토큰이 아닙니다. 요청하신 로직에서는 엑세스 토큰으로만 처리가 가능합니다.");
}
if (claims.getExpiration().before(new Date())) {
throw new AccessTokenExpiredException("액세스 토큰이 만료되었습니다. 리프레시 토큰으로 다시 액세스 토큰을 발급받으세요.");
}
return claims.getSubject();
}

public String extractEmailFromRefreshToken(String refreshToken) {
Claims claims = parseClaims(refreshToken);
if (!"refresh".equals(claims.get("tokenType", String.class))) {
throw new BadRequestException("해당 토큰은 리프레쉬 토큰이 아닙니다. 요청하신 로직에서는 리프레쉬 토큰만 사용이 가능합니다.");
}
return claims.getSubject();
}

private Claims parseClaims(String token) {
try {
claims = Jwts.parserBuilder()
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new InvalidJwtException(e.getMessage());
}

if (claims.getExpiration().before(new Date())) {
throw new AccessTokenExpiredException("액세스 토큰이 만료되었습니다. 리프레시 토큰으로 다시 액세스 토큰을 발급받으세요.");
throw new AccessTokenExpiredException(e.getMessage());
}

return claims.getSubject();
}

public TokenResponse refreshAccessToken(String refreshToken) {
String email = extractEmail(refreshToken);
String email = extractEmailFromRefreshToken(refreshToken);

String storedRefreshToken = (String) redisTemplate.opsForHash().get(email, "refreshToken");
Long createdAt = Long.parseLong((String) redisTemplate.opsForHash().get(email, "createdAt"));

String storedRefreshToken = redisTemplate.opsForValue().get(email);
if (((System.currentTimeMillis() - createdAt) / 1000 / 60) < 1) {
throw new ConflictException("1분 이내에 발급받은 refresh Token이 있습니다.");
}

if (storedRefreshToken == null) {
throw new InvalidJwtException("토큰이 만료되었습니다. 재로그인이 필요합니다.");
Expand All @@ -93,4 +119,11 @@ public TokenResponse refreshAccessToken(String refreshToken) {

return new TokenResponse(newAccessToken, newRefreshToken);
}

public void deleteAllDataFromRedis(){
redisTemplate.getConnectionFactory()
.getConnection()
.serverCommands()
.flushAll();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public interface CallbackRepository extends JpaRepository<Callback, Long> {

Page<Callback> findAllBySeniorIn(List<Senior> seniors, Pageable pageable);

List<Callback> findAllByStatusAndPendingCompleteTimeBefore(Callback.Status status, LocalDateTime dateTime);
List<Callback> findAllByStatusAndPendingCompleteTimeBetween(Callback.Status status, LocalDateTime startDateTime, LocalDateTime endDateTime);

boolean existsBySeniorAndStatusIn(Senior senior, List<Callback.Status> statuses);
}
Loading