Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8c1970d
feat: user login start
etoile0626 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
c95cfca
feat: jwt test 코딩중
etoile0626 May 2, 2025
b605397
feat: jwt token 발행 test
etoile0626 May 2, 2025
718421e
feat: jwt access token, refresh token 구현(토큰 db 저장, 로그인 미완)
etoile0626 May 2, 2025
8e9604d
feat: jwt 엑세스 토큰 발급 테스트 완료 (로그인, 로그아웃 미완)
etoile0626 May 2, 2025
4842a27
fix: properties fix
etoile0626 May 2, 2025
b3eb8df
fix: properties fix
etoile0626 May 2, 2025
3179037
feat: 로그인 관련 일부 클래스 위치 수정 및 로그인 기능 구현(로그아웃x)
etoile0626 May 7, 2025
fe7189f
fix: 테스트 코드 수정
etoile0626 May 7, 2025
9d2c8cc
feat: 로그아웃 구현 중 임시저장
etoile0626 May 7, 2025
3c970c5
feat: 토큰 예외 발생로직 추가, 로그인 로그아웃 구현 완성(아마도 제발요)
etoile0626 May 8, 2025
665d08a
feat: 토큰 예외 발생로직 추가, 로그인 로그아웃 구현 완성(아마도 제발요)
etoile0626 May 8, 2025
8072471
fix: yml 파일 수정
etoile0626 May 8, 2025
1b21e50
comment: auth 컨트롤러 로그인 메서드 설명 추가
etoile0626 May 9, 2025
fb99f0c
feat: OAuth 구글로그인 구현 중 임시 저장
etoile0626 May 9, 2025
19e4216
feat: 구글 소셜 로그인 api 구현
etoile0626 May 12, 2025
ae22faa
refactor: 토큰 검증 중 에러 메세지 수정
etoile0626 May 12, 2025
c74b940
rename: oauth버전- 파일위치 변경
etoile0626 May 13, 2025
286e839
Merge branch 'develop' into feature/oauth
sangyunpark99 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
20 changes: 20 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +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: 21 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,29 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

implementation 'org.redisson:redisson-spring-boot-starter:3.25.2'
//test
testImplementation 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.apache.commons:commons-pool2'
//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