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

[Feat] refresh token으로 로그인 상태 유지 #24

Merged
merged 30 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5a352fa
feat: 로그인 시 refresh token 발급 및 헤더에 응답
helenason Feb 25, 2025
4da0d05
feat: 매 요청에서 token 검증 및 재발급
helenason Feb 25, 2025
a3867ac
test: OAuthService 로그인 로직
helenason Feb 25, 2025
7892636
refac: refresh token 만료 환경 변수 추출
helenason Feb 25, 2025
0830840
test: UuidTokenProvider 클래스
helenason Feb 25, 2025
3b18bd7
fix: 인증 로직 통합 테스트하면서 잘못된 로직 수정
helenason Feb 25, 2025
360d0e9
refac: 인증 필터 이름 변경
helenason Feb 25, 2025
3bc77ac
test: mock user 추출
helenason Feb 25, 2025
1e837de
test: ServiceTest 추출
helenason Feb 25, 2025
9bcacd8
refac: 패키지 구조 기존 구조랑 통일
helenason Feb 25, 2025
aef9b77
refac: 스웨거 문서 수정
helenason Feb 25, 2025
57f96b2
refac: 불필요한 생성자 제거
helenason Feb 27, 2025
71c54b2
refac: 레디스 데이터 초기화 메서드 테스트로 이동
helenason Feb 27, 2025
c037a0f
refac: 로그인 시 응답 토큰 위치 헤더 -> 바디
helenason Feb 27, 2025
56f06c0
refac: oauth url API 응답 형식 통일
helenason Feb 27, 2025
31b5fe3
test: 응답 형태 변경에 따른 테스트 코드 수정
helenason Feb 27, 2025
4a92e28
feat: 응답 DTO 스웨거 설명 추가
helenason Feb 27, 2025
af5de97
refac: 공통 응답 클래스 data 필드 타입 변경 Object -> 제네릭
helenason Feb 27, 2025
7ad3a48
refac: refresh token 발급 방식 변경
helenason Feb 27, 2025
7ad1105
refac: 서비스 계층의 책임 분리를 위해 TokenProvider 추가
helenason Feb 27, 2025
ab11995
refac: Auth 에러 코드 통합 및 static import 제거
helenason Feb 27, 2025
bfc7fbc
refac: 커밋 7ad1105d 보완
helenason Feb 27, 2025
65c6684
refac: filter 에러 응답 형태 통일
helenason Feb 27, 2025
e20efb8
refac: 인증 도메인 용어 통일 oauth -> auth
helenason Feb 28, 2025
9902ae2
refac: 패키지 위치 통일
helenason Feb 28, 2025
a451d3e
refac: 불필요한 커스텀 예외 제거
helenason Feb 28, 2025
51e56e3
refac: 인증 관련 클래스 auth feature 패키지로 이동
helenason Feb 28, 2025
196cf63
docs: 스웨거 문서 정리
helenason Feb 28, 2025
284fc05
refac: 인증 필터에서 상황에 따른 예외 반환
helenason Feb 28, 2025
dd082d4
refac: login API HTTP method GET -> POST
helenason Feb 28, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.evenly.took.feature.auth.api;

import org.springframework.stereotype.Component;

import com.evenly.took.global.exception.auth.jwt.AuthErrorCode;
import com.evenly.took.global.exception.auth.oauth.InvalidTokenException;

import jakarta.servlet.http.HttpServletRequest;

@Component
public class HeaderHandler {

private static final String HEADER_KEY_OF_AUTH = "Authorization";
private static final String HEADER_VALUE_PREFIX_OF_AUTH = "Bearer ";

public String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(HEADER_KEY_OF_AUTH);
validateAuthHeader(bearerToken);
return bearerToken.substring(HEADER_VALUE_PREFIX_OF_AUTH.length());
}

private void validateAuthHeader(String bearerToken) {
if (bearerToken == null || !bearerToken.startsWith(HEADER_VALUE_PREFIX_OF_AUTH)) {
throw new InvalidTokenException(AuthErrorCode.JWT_UNAUTHORIZED);
}
}
}
29 changes: 15 additions & 14 deletions src/main/java/com/evenly/took/feature/auth/api/OAuthApi.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.evenly.took.feature.auth.api;

import java.io.IOException;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

import com.evenly.took.feature.auth.domain.OAuthType;
import com.evenly.took.feature.auth.dto.response.AuthResponse;
import com.evenly.took.feature.auth.dto.response.OAuthUrlResponse;
import com.evenly.took.global.response.SuccessResponse;

import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -15,30 +15,31 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;

@Tag(name = "[1. OAuth]")
public interface OAuthApi {

@Operation(
summary = "소셜 로그인 인가 URL 리다이렉트",
description = "지정된 OAuthType에 따른 소셜 로그인 인가 코드 요청 URL로 클라이언트를 리다이렉트합니다."
)
@ApiResponse(responseCode = "302", description = "리다이렉트 성공")
description = "지정된 OAuthType에 따른 소셜 로그인 인가 코드 요청 URL로 클라이언트를 리다이렉트합니다.")
@ApiResponse(
responseCode = "302",
description = "리다이렉트 성공",
content = @Content(schema = @Schema(implementation = SuccessResponse.class)))
@GetMapping("/{oauthType}")
void redirectAuthRequestUrl(
SuccessResponse<OAuthUrlResponse> redirectAuthRequestUrl(
@Parameter(description = "소셜 공급자 타입 (예: GOOGLE, KAKAO, APPLE)", required = true, example = "GOOGLE")
@PathVariable OAuthType oauthType,
HttpServletResponse response) throws IOException;
@PathVariable OAuthType oauthType);

@Operation(
summary = "소셜 로그인 및 JWT 토큰 발급",
description = "소셜 로그인 인가 코드를 통해 JWT 토큰(Access Token)을 발급받습니다."
)
@ApiResponse(responseCode = "200", description = "로그인 성공",
summary = "소셜 로그인 및 토큰 발급",
description = "소셜 로그인 인가 코드를 통해 토큰(Access Token & Refresh Token)을 발급받습니다.")
@ApiResponse(
responseCode = "200",
description = "로그인 성공",
content = @Content(schema = @Schema(implementation = SuccessResponse.class)))
@GetMapping("/login/{oauthType}")
SuccessResponse login(
SuccessResponse<AuthResponse> login( // TODO 에러 응답 추가
@Parameter(description = "소셜 공급자 타입 (예: GOOGLE, KAKAO, APPLE)", required = true, example = "GOOGLE")
@PathVariable OAuthType oauthType,
@Parameter(description = "소셜 서버로부터 전달받은 인가 코드", required = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
package com.evenly.took.feature.auth.api;

import java.io.IOException;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.evenly.took.feature.auth.application.OAuthService;
import com.evenly.took.feature.auth.domain.OAuthType;
import com.evenly.took.feature.auth.dto.response.JwtResponse;
import com.evenly.took.feature.auth.dto.request.RefreshTokenRequest;
import com.evenly.took.feature.auth.dto.response.AuthResponse;
import com.evenly.took.feature.auth.dto.response.OAuthUrlResponse;
import com.evenly.took.feature.auth.dto.response.TokenResponse;
import com.evenly.took.global.response.SuccessResponse;

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -22,15 +24,20 @@ public class OAuthController implements OAuthApi {
private final OAuthService oauthService;

@GetMapping("/api/oauth/{oauthType}")
public void redirectAuthRequestUrl(@PathVariable OAuthType oauthType, HttpServletResponse response) throws
IOException {
String url = oauthService.getAuthCodeRequestUrl(oauthType);
response.sendRedirect(url);
public SuccessResponse<OAuthUrlResponse> redirectAuthRequestUrl(@PathVariable OAuthType oauthType) {
OAuthUrlResponse response = oauthService.getAuthCodeRequestUrl(oauthType);
return SuccessResponse.of(HttpStatus.FOUND, response);
}

@GetMapping("/api/oauth/login/{oauthType}")
public SuccessResponse login(@PathVariable OAuthType oauthType, @RequestParam String code) {
JwtResponse jwtResponse = oauthService.loginAndGenerateToken(oauthType, code);
return SuccessResponse.of(jwtResponse.accessToken());
public SuccessResponse<AuthResponse> login(@PathVariable OAuthType oauthType, @RequestParam String code) {
AuthResponse response = oauthService.loginAndGenerateToken(oauthType, code);
return SuccessResponse.of(response);
}

@GetMapping("/api/oauth/refresh")
public SuccessResponse<TokenResponse> refreshToken(@RequestBody RefreshTokenRequest request) {
TokenResponse response = oauthService.refreshToken(request);
return SuccessResponse.of(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
import org.springframework.stereotype.Service;

import com.evenly.took.feature.auth.domain.OAuthType;
import com.evenly.took.feature.auth.dto.response.JwtResponse;
import com.evenly.took.feature.auth.dto.TokenDto;
import com.evenly.took.feature.auth.dto.request.RefreshTokenRequest;
import com.evenly.took.feature.auth.dto.response.AuthResponse;
import com.evenly.took.feature.auth.dto.response.OAuthUrlResponse;
import com.evenly.took.feature.auth.dto.response.TokenResponse;
import com.evenly.took.feature.user.dao.UserRepository;
import com.evenly.took.feature.user.domain.User;
import com.evenly.took.global.security.client.AuthCodeRequestUrlProviderComposite;
import com.evenly.took.global.security.client.UserClientComposite;
import com.evenly.took.global.security.jwt.JwtTokenProvider;

import lombok.RequiredArgsConstructor;

Expand All @@ -19,19 +22,24 @@ public class OAuthService {
private final AuthCodeRequestUrlProviderComposite authCodeComposite;
private final UserClientComposite userClientComposite;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final TokenProvider tokenProvider;

public String getAuthCodeRequestUrl(OAuthType oauthType) {
return authCodeComposite.provide(oauthType);
public OAuthUrlResponse getAuthCodeRequestUrl(OAuthType oauthType) {
String url = authCodeComposite.provide(oauthType);
return new OAuthUrlResponse(url);
}

public JwtResponse loginAndGenerateToken(OAuthType oauthType, String authCode) {
public AuthResponse loginAndGenerateToken(OAuthType oauthType, String authCode) {
User user = userClientComposite.fetch(oauthType, authCode);
User savedUser = userRepository.findByOauthIdentifier(user.getOauthIdentifier())
.orElseGet(() -> userRepository.save(user));
TokenDto tokens = tokenProvider.provideTokens(savedUser);
return new AuthResponse(tokens, user);
}

String accessToken = jwtTokenProvider.generateAccessToken(savedUser);

return new JwtResponse(accessToken);
public TokenResponse refreshToken(RefreshTokenRequest request) {
String refreshToken = request.refreshToken();
String accessToken = tokenProvider.provideAccessTokenByRefreshToken(refreshToken);
return new TokenResponse(accessToken, refreshToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.evenly.took.feature.auth.application;

import org.springframework.stereotype.Component;

import com.evenly.took.feature.auth.dto.TokenDto;
import com.evenly.took.feature.user.domain.User;
import com.evenly.took.global.security.auth.JwtTokenProvider;
import com.evenly.took.global.security.auth.UuidTokenProvider;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class TokenProvider {

private final JwtTokenProvider jwtTokenProvider;
private final UuidTokenProvider uuidTokenProvider;

public void validateAccessToken(String accessToken) {
jwtTokenProvider.validateToken(accessToken);
}

public TokenDto provideTokens(User user) {
String accessToken = jwtTokenProvider.generateAccessToken(user.getId().toString());
String refreshToken = uuidTokenProvider.generateRefreshToken(user.getId().toString());
return new TokenDto(accessToken, refreshToken);
}

public String provideAccessTokenByRefreshToken(String refreshToken) {
String userId = uuidTokenProvider.getUserId(refreshToken);
return jwtTokenProvider.generateAccessToken(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.evenly.took.feature.auth.dto;

public record TokenDto(
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.evenly.took.feature.auth.dto.request;

public record RefreshTokenRequest(
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.evenly.took.feature.auth.dto.response;

import com.evenly.took.feature.auth.dto.TokenDto;
import com.evenly.took.feature.user.domain.User;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "로그인 응답")
public record AuthResponse(
@Schema(description = "액세스 토큰 및 리프레시 토큰 정보") TokenResponse token,
@Schema(description = "로그인 사용자 정보") UserResponse user
) {

public AuthResponse(TokenDto tokens, User user) {
this(new TokenResponse(tokens.accessToken(), tokens.refreshToken()),
new UserResponse(user));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.evenly.took.feature.auth.dto.response;

public record JwtResponse(
String accessToken
public record OAuthUrlResponse(
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.evenly.took.feature.auth.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "로그인 토큰 정보")
public record TokenResponse(
@Schema(description = "액세스 토큰 정보") String accessToken,
@Schema(description = "리프레시 토큰 정보") String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.evenly.took.feature.auth.dto.response;

import com.evenly.took.feature.user.domain.User;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "로그인 사용자 정보")
public record UserResponse(
@Schema(description = "사용자 ID") Long id,
@Schema(description = "사용자 이름") String name
) {

public UserResponse(User user) {
this(user.getId(), user.getName());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import com.evenly.took.global.config.properties.jwt.JwtProperties;
import com.evenly.took.global.config.properties.jwt.AuthProperties;

@EnableConfigurationProperties({
JwtProperties.class,
AuthProperties.class,
})
@Configuration
public class PropertiesConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
@ConfigurationProperties(prefix = "auth")
public record AuthProperties(
String accessTokenSecret,
Long accessTokenExpirationTime
Long accessTokenExpirationTime,
Long refreshTokenExpirationTime
) {

public Long accessTokenExpirationMilliTime() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import jakarta.servlet.http.HttpServletResponse;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
Expand All @@ -34,11 +36,16 @@ public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exceptio
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
defaultFilterChain(http);

http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/health").permitAll()
.requestMatchers("/api/oauth").permitAll()
.anyRequest().authenticated());
http.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) -> {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
})
) // TODO 예외 응답 형식 통일
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/health").permitAll()
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated());

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.evenly.took.global.exception.auth.jwt;

import org.springframework.http.HttpStatus;

import com.evenly.took.global.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AuthErrorCode implements ErrorCode {

OAUTH_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "OAuth 타입을 찾을 수 없습니다."),
JWT_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "JWT를 찾을 수 없습니다."),
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "refresh token이 만료되었습니다."),
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "access token이 만료되었습니다."),
INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "access token이 유효하지 않습니다."),
;

private final HttpStatus status;
private final String message;
}

This file was deleted.

Loading