Skip to content

Commit

Permalink
Merge pull request #201 from kakao-tech-campus-2nd-step3/Weekly
Browse files Browse the repository at this point in the history
10์ฃผ์ฐจ ์‚ฐ์ถœ๋ฌผ(Weekly -> Develop)
  • Loading branch information
zzoe2346 authored Nov 8, 2024
2 parents 729681d + 9b41fec commit ccb2a22
Show file tree
Hide file tree
Showing 62 changed files with 3,814 additions and 724 deletions.
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

0 comments on commit ccb2a22

Please sign in to comment.