Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
8c1970d
feat: user login start
etoile0626 May 1, 2025
aa13cfd
Feat : Setting v2 file
Suhun0331 May 1, 2025
0e8099b
Feat : Reserve의 status 속성 타입을 Enum으로 변경
tmdals1207 May 1, 2025
30a0758
feat: 유저 디테일 상속
etoile0626 May 1, 2025
c1e7619
feat: user 클래스 변경(age-int, builder 생성자 추가 등), 회원가입 구현
etoile0626 May 1, 2025
c20107d
feat: @Transactional 추가
etoile0626 May 1, 2025
c63b41d
fix: @Transactional 추가
etoile0626 May 1, 2025
3c8c743
Merge remote-tracking branch 'origin/feature/user' into feature/user
etoile0626 May 1, 2025
2d64ee8
feat: login 구현 ing
etoile0626 May 1, 2025
e991863
Merge pull request #8 from Ureca-QuickPick/feature/v1
sangyunpark99 May 1, 2025
1a62107
Feat : Add v2 file
Suhun0331 May 2, 2025
78ac016
Merge branch 'develop' of https://github.com/Ureca-QuickPick/QuickPic…
Suhun0331 May 2, 2025
e41f10b
Feat : 티켓 예매/ 취소 api 추가
Suhun0331 May 2, 2025
3d90af1
Feat : add InitController
Suhun0331 May 2, 2025
c95cfca
feat: jwt test 코딩중
etoile0626 May 2, 2025
b069bb7
Feat : First Test_V2_Test1
Suhun0331 May 2, 2025
9b487f3
Feat : V2_Test2
Suhun0331 May 2, 2025
b605397
feat: jwt token 발행 test
etoile0626 May 2, 2025
718421e
feat: jwt access token, refresh token 구현(토큰 db 저장, 로그인 미완)
etoile0626 May 2, 2025
cd7995e
Feat : V2_Test3(Redis 분산락)
Suhun0331 May 2, 2025
72103eb
Feat : V2_Test3(Redis 분산락)
Suhun0331 May 2, 2025
4d8beac
Feat : V2_Test3(Redis 분산락)
Suhun0331 May 2, 2025
0aee0d0
Feat : V1_Test1 (DB 락 X)
tmdals1207 May 2, 2025
c9cf182
Refactor : unused import 정리
tmdals1207 May 2, 2025
e8e7c00
Feat : V1_Test2(Pessimistic Lock)
tmdals1207 May 2, 2025
8e9604d
feat: jwt 엑세스 토큰 발급 테스트 완료 (로그인, 로그아웃 미완)
etoile0626 May 2, 2025
83a041c
Feat : V1_Test2(Pessimistic Lock)
tmdals1207 May 2, 2025
4842a27
fix: properties fix
etoile0626 May 2, 2025
b3eb8df
fix: properties fix
etoile0626 May 2, 2025
d83c9e8
Feat : V2_TEST4(임계구역 최소화)
Suhun0331 May 4, 2025
c971e13
Feat : V2_TEST5(Redis-Lua Script 추가)
Suhun0331 May 4, 2025
32167a9
Feat : V2_TEST6(Lua 중복 예매 방지 + 예외 복구)
Suhun0331 May 4, 2025
10bf5f4
Feat : V1_Test2(open-in-view + FetchJoin + DTO)
tmdals1207 May 4, 2025
5b44607
Feat : V1_Test3(open-in-view + FetchJoin + DTO)
tmdals1207 May 4, 2025
a603b8a
Merge remote-tracking branch 'origin/feature/v1' into feature/v1
tmdals1207 May 4, 2025
05a1b71
Refactor : 도메인 형식 표준에 맞게 변경
tmdals1207 May 6, 2025
e472bde
Feat : 서버 실행 시 init 자동실행 하도록 변경
tmdals1207 May 6, 2025
7ac2b2b
Fix : Test 부분에서 속성이 null인 부분 때문에 생기는 에러 주석 처리
tmdals1207 May 6, 2025
af66a72
Feat : V1_Test4(비관적 락 + 네이티브 쿼리 + 인덱스)
tmdals1207 May 6, 2025
3179037
feat: 로그인 관련 일부 클래스 위치 수정 및 로그인 기능 구현(로그아웃x)
etoile0626 May 7, 2025
fe7189f
fix: 테스트 코드 수정
etoile0626 May 7, 2025
7fb8d5e
Feat : 티켓 취소 api 추가(Lua)
Suhun0331 May 7, 2025
9d2c8cc
feat: 로그아웃 구현 중 임시저장
etoile0626 May 7, 2025
7173275
Feat : V2_TEST7(EVALSHA로 Lua 캐싱)
Suhun0331 May 8, 2025
6949622
Feat : V2_TEST7(EVALSHA로 Lua 캐싱+Ticket 캐싱)
Suhun0331 May 8, 2025
3c970c5
feat: 토큰 예외 발생로직 추가, 로그인 로그아웃 구현 완성(아마도 제발요)
etoile0626 May 8, 2025
665d08a
feat: 토큰 예외 발생로직 추가, 로그인 로그아웃 구현 완성(아마도 제발요)
etoile0626 May 8, 2025
8072471
fix: yml 파일 수정
etoile0626 May 8, 2025
3cdf118
Feat : V1_Test5(비관적 락 + 중복방지 + Projection + 네이티브 쿼리)
tmdals1207 May 8, 2025
21020d8
Feat : V1_Test6(비관적 락 + 중복방지 + In-Memory 캐시 + Sharding + 네이티브 쿼리)
tmdals1207 May 8, 2025
1b21e50
comment: auth 컨트롤러 로그인 메서드 설명 추가
etoile0626 May 9, 2025
906cf4a
Feat : 티켓 예약 취소 로직 추가
tmdals1207 May 9, 2025
fb99f0c
feat: OAuth 구글로그인 구현 중 임시 저장
etoile0626 May 9, 2025
8a895de
Refactor : Feat : V2_TEST4 코드 수정
Suhun0331 May 10, 2025
19e4216
feat: 구글 소셜 로그인 api 구현
etoile0626 May 12, 2025
ae22faa
refactor: 토큰 검증 중 에러 메세지 수정
etoile0626 May 12, 2025
10dbee8
Chore : V2 패키지 구조 변경
Suhun0331 May 13, 2025
f5041a0
Feat : Add V2 Test 1-7
Suhun0331 May 13, 2025
c74b940
rename: oauth버전- 파일위치 변경
etoile0626 May 13, 2025
07671d8
Rename : 병합 전 폴더 표준화
tmdals1207 May 13, 2025
286e839
Merge branch 'develop' into feature/oauth
sangyunpark99 May 13, 2025
96ae640
Merge pull request #12 from Ureca-QuickPick/feature/oauth
etoile0626 May 13, 2025
c91f7a5
Rename : 병합 전 폴더 표준화
tmdals1207 May 13, 2025
1d5a977
Rename : 병합 전 폴더 표준화
tmdals1207 May 13, 2025
34c1d67
Fix : 병합 오류 수정
tmdals1207 May 13, 2025
03f6cc2
Fix : 병합 오류 수정
tmdals1207 May 13, 2025
1d8cca7
Merge pull request #15 from Ureca-QuickPick/feature/v1
tmdals1207 May 13, 2025
0ed6514
Refactor : Rename init -> init2
Suhun0331 May 13, 2025
b278824
Merge pull request #16 from Ureca-QuickPick/feature/v2
tmdals1207 May 13, 2025
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
26 changes: 26 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
mysql user: 'test'
mysql password: 'testPW'

- name: Start Redis
uses: supercharge/[email protected]

- name: Setup Gradle
uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5

Expand All @@ -52,6 +55,29 @@ jobs:
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.properties.hibernate.format_sql=true


jwt.issuer=${{ secrets.JWT_ISSUER }}
jwt.secret_key=${{ secrets.JWT_SECRET_KEY }}

# OAuth
spring.security.oauth2.client.registration.google.client-id=${{ secrets.GOOGLE_CLIENT_ID }}
spring.security.oauth2.client.registration.google.client-secret=${{ secrets.GOOGLE_CLIENT_SECRET }}
spring.security.oauth2.client.registration.google.scope=email,profile

spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth
spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token
spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v3/userinfo

#Redis
spring.data.redis.host=localhost

spring.data.redis.port=6379

spring.data.redis.lettuce.pool.max-active=10
spring.data.redis.lettuce.pool.max-idle=10
spring.data.redis.lettuce.pool.min-idle=1
spring.data.redis.lettuce.pool.max-wait=1000ms
EOT
shell: bash

Expand Down
24 changes: 24 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

//test
testImplementation 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

//spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

//jwt
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'

// Spring Security OAuth2 클라이언트 (구글/카카오 로그인 등)
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.data:spring-data-redis'
implementation 'org.apache.commons:commons-pool2' // 커넥션 풀
implementation 'org.redisson:redisson-spring-boot-starter:3.25.2'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.quickpick.ureca.OAuth.auth.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@Setter
@ConfigurationProperties(prefix = "jwt")
public class JwtPropertiesOAuth {
private String issuer;
private String secretKey;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.quickpick.ureca.OAuth.auth.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.quickpick.ureca.OAuth.auth.dto.UserLoginResponseOAuth;
import com.quickpick.ureca.OAuth.auth.service.RefreshTokenServiceOAuth;
import com.quickpick.ureca.OAuth.user.domain.UserOAuth;
import com.quickpick.ureca.OAuth.user.service.UserServiceOAuth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;

@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandlerOAuth implements AuthenticationSuccessHandler { //OAuth 인증 성공시 jwt 발급 및 리디렉션
private final TokenProviderOAuth tokenProvider;
private final UserServiceOAuth userService;
private final RefreshTokenServiceOAuth refreshTokenService;
private final ObjectMapper objectMapper;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {

OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");

// 사용자 DB에 저장 (없으면 새로 추가)
UserOAuth user = userService.findById(email)
.orElseGet(() -> userService.saveFromOAuth2(oAuth2User));

// JWT 발급
String accessToken = tokenProvider.generateToken(user, Duration.ofHours(2)); // Access token
String refreshToken = tokenProvider.generateToken(user, Duration.ofDays(14)); // Refresh token (필요시 DB 저장)

// Refresh token을 DB에 저장
refreshTokenService.save(user.getUserId(), refreshToken);

// 기존 로그인 응답 DTO 사용
UserLoginResponseOAuth responseDto = new UserLoginResponseOAuth(accessToken, refreshToken);

response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.setStatus(HttpServletResponse.SC_OK);
objectMapper.writeValue(response.getWriter(), responseDto);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.quickpick.ureca.OAuth.auth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfigOAuth {

@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer()); // 토큰은 일반 문자열이므로 String 직렬화면 충분
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.quickpick.ureca.OAuth.auth.config;

import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class TokenAuthenticationFilterOAuth extends OncePerRequestFilter {
private final TokenProviderOAuth tokenProvider;
private final RedisTemplate<String, String> redisTemplate;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String BEARER = "Bearer ";


@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//요청 헤더의 auth 키의 값 조회
String authHeader = request.getHeader(HEADER_AUTHORIZATION);
String token = getAccessToken(authHeader); //접두사 제거해서 토큰 가져오기

try {
if (token != null) {
tokenProvider.validToken(token); //예외가 발생하면 catch문으로
if (isBlacklisted(token)) { //블랙리스트에 있는 토큰이면 예외 발생
throw new JwtException("Blacklisted token");
}
//토큰이 유효하고 블랙리스트에 없다면 인증 정보 설정
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}

filterChain.doFilter(request, response);
} catch (JwtException e) {
setErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
}
}
//에러 메세지 설정 메서드
private void setErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");

String responseBody = String.format("{\"error\": \"%s\"}", message);
response.getWriter().write(responseBody);
}

private boolean isBlacklisted(String token) {
//redis 내 블랙리스트에 있는지 검사
return redisTemplate.hasKey("blacklist:" + token);
}

private String getAccessToken(String authHeader) {
if (authHeader != null && authHeader.startsWith(BEARER)) {
return authHeader.substring(BEARER.length());
}
return null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.quickpick.ureca.OAuth.auth.config;

import com.quickpick.ureca.OAuth.user.domain.UserOAuth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;

@RequiredArgsConstructor
@Service
public class TokenProviderOAuth {

private final JwtPropertiesOAuth jwtProperties;

public String generateToken(UserOAuth user, Duration expiredAt) {
Date now = new Date();
return makeToken(user, new Date( now.getTime() + expiredAt.toMillis()));
} // expriedAt 만큼의 유효기간을 가진 토큰 생성

public String makeToken(UserOAuth user, Date expiry) {

return Jwts.builder()
.issuer(jwtProperties.getIssuer())
.expiration(expiry)
.subject(user.getId())
.claim("user_id", user.getUserId())
.signWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)))
.compact();
}

//토큰 검증 메서드
public void validToken(String token) {
try{
Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token);
} catch (SecurityException | MalformedJwtException e) { //서명이 불일치한 경우 / 구조가 손상된 경우
throw new JwtException("Invalid JWT signature");
} catch (ExpiredJwtException e) { //만료된 토큰인 경우
throw new JwtException("JWT token expired");
} catch (UnsupportedJwtException e) { //지원하지 않는 토큰인 경우
throw new JwtException("Unsupported JWT token");
} catch (IllegalArgumentException e) { //토큰이 아예 없거나 비정상적으로 전달된 경우?
throw new JwtException("JWT token is invalid");
}
}

public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities
= Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

return new UsernamePasswordAuthenticationToken(
new org.springframework.security.core.userdetails.User(
claims.getSubject(), "", authorities)
, token
, authorities);
}

public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("user_id", Long.class);
}

//Claims 가져오기
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload(); //getBody()가 deprecated되어 이걸 쓸 것
}

//남은 토큰 유효시간 계산
public long getRemainingValidity(String token) {
Claims claims = getClaims(token);
return claims.getExpiration().getTime() - System.currentTimeMillis();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.quickpick.ureca.OAuth.auth.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfigOAuth {

private final UserDetailsService userDetailsService;
private final TokenProviderOAuth tokenProvider; // TokenProvider 추가
private final RedisTemplate<String, String> redisTemplate;

// Static 리소스는 인증 없이 접근
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (webSecurity) -> webSecurity.ignoring()
.requestMatchers(new AntPathRequestMatcher("/static/**"));
}

// Security Filter Chain
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, OAuth2LoginSuccessHandlerOAuth oAuth2LoginSuccessHandler) throws Exception {
return http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //서버 세션 비활성화(jwt 사용하므로)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/signup", "/auth/token", "/oauth2/**").permitAll() // 로그인, 회원가입, 토큰 재발급, 소셜로그인은 인증 없이 접근
.anyRequest().authenticated() // 그 외 요청은 인증 필요
)
.formLogin(AbstractHttpConfigurer::disable) //폼로그인 비활성화
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (API 서버일 경우)
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2LoginSuccessHandler) // 소셜로그인 설정
)
.addFilterBefore(new TokenAuthenticationFilterOAuth(tokenProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class) // JWT 필터 폼 로그인 필터 앞에 추가
.build();
}

// AuthenticationManager 설정 (필요한가?)
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailsService userDetailsService) throws Exception {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(bCryptPasswordEncoder);
return new ProviderManager(authProvider);
}

// BCryptPasswordEncoder 설정
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
Loading
Loading