Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -8,12 +8,13 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import org.tuna.zoopzoop.backend.domain.auth.service.KakaoUserInfoService;
import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData;
import org.tuna.zoopzoop.backend.domain.auth.entity.AuthResult;
import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken;
import org.tuna.zoopzoop.backend.domain.auth.service.RefreshTokenService;
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
import org.tuna.zoopzoop.backend.domain.member.service.MemberService;
import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties;
import org.tuna.zoopzoop.backend.global.rsData.RsData;
import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil;

Expand All @@ -23,69 +24,88 @@
@Tag(name = "ApiV1AuthController", description = "인증/인가 REST API 컨트롤러")
public class ApiV1AuthController {
private final JwtUtil jwtUtil;
private final MemberService memberService;
private final JwtProperties jwtProperties;
private final KakaoUserInfoService kakaoUserInfoService;
private final WebClient webClient;
private final RefreshTokenService refreshTokenService;
private final AuthResult authResult;

/**
* 사용자 로그아웃 API
* @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입.
*/
@GetMapping("/logout")
@Operation(summary = "사용자 로그아웃")
public ResponseEntity<RsData<Void>> logout(HttpServletResponse response) {
public ResponseEntity<RsData<Void>> logout(
@CookieValue(name = "sessionId")
String sessionId,
HttpServletResponse response) {

// 서버에서 RefreshToken 삭제
refreshTokenService.deleteBySessionId(sessionId);

// 클라이언트 쿠키 삭제 (AccessToken + SessionId)
ResponseCookie accessCookie = ResponseCookie.from("accessToken", "")
.httpOnly(true)
.path("/")
.maxAge(0) // 쿠키 삭제
.maxAge(0)
.sameSite("Lax")
.build();

ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "")
ResponseCookie sessionCookie = ResponseCookie.from("sessionId", "")
.httpOnly(true)
.path("/")
.maxAge(0) // 쿠키 삭제
.maxAge(0)
.sameSite("Lax")
.build();

response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString());

return ResponseEntity
.status(HttpStatus.OK)
.body(new RsData<>(
"200",
"정상적으로 로그아웃 했습니다.",
null
)
);
.body(new RsData<>("200", "정상적으로 로그아웃 했습니다.", null));
}

/**
* refreshToken 기반으로 accessToken 재발급
* @param refreshToken 쿠키에 포함된 현재 로그인한 사용자의 refreshToken
* @param sessionId 쿠키에 포함된 현재 로그인한 사용자의 sessionId.
* @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입.
*/

@PostMapping("/refresh")
@Operation(summary = "사용자 액세스 토큰 재발급 (리프레시 토큰이 유효할 경우)")
public ResponseEntity<RsData<Void>> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse response) {
@Operation(summary = "사용자 액세스 토큰 재발급 (서버 저장 RefreshToken 사용)")
public ResponseEntity<RsData<Void>> refreshToken(
@CookieValue(name = "sessionId")
String sessionId,
HttpServletResponse response
) {

if (refreshToken == null || !jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) {
if (sessionId == null) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new RsData<>(
"401",
"유효하지 않은 리프레시 토큰입니다.",
null
));
.body(new RsData<>("401", "세션이 존재하지 않습니다.", null));
}

// sessionId로 RefreshToken 조회
RefreshToken refreshTokenEntity;
try {
refreshTokenEntity = refreshTokenService.getBySessionId(sessionId);
} catch (AuthenticationException e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new RsData<>("401", e.getMessage(), null));
}

String refreshToken = refreshTokenEntity.getRefreshToken();

// RefreshToken 유효성 검사
if (!jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new RsData<>("401", "유효하지 않은 리프레시 토큰입니다.", null));
}

String providerKey = jwtUtil.getProviderKeyFromToken(refreshToken);
Member member = memberService.findByProviderKey(providerKey);
Member member = refreshTokenEntity.getMember();

// 새 AccessToken 발급
String newAccessToken = jwtUtil.generateToken(member);

ResponseCookie accessCookie = ResponseCookie.from("accessToken", newAccessToken)
Expand All @@ -97,12 +117,33 @@ public ResponseEntity<RsData<Void>> refreshToken(@CookieValue(name = "refreshTok

response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());

return ResponseEntity
.status(HttpStatus.OK)
.body(new RsData<>("200", "액세스 토큰을 재발급 했습니다.", null));
}

@GetMapping("/result")
@Operation(summary = "확장프로그램 백그라운드 풀링 대응 API")
public ResponseEntity<RsData<AuthResultData>> pullingResult(
@RequestParam String state
) {
AuthResultData resultData = authResult.get(state);
if(resultData == null) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new RsData<>(
"404",
"state에 해당하는 토큰이 준비되지 않았거나, 잘못된 state 입니다.",
null
)
);
}
return ResponseEntity
.status(HttpStatus.OK)
.body(new RsData<>(
"200",
"액세스 토큰을 재발급 했습니다.",
null
"200",
"토큰이 정상적으로 발급되었습니다.",
resultData
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.tuna.zoopzoop.backend.domain.auth.dto;

public class AuthResultData {
private final String accessToken;
private final String sessionId;

public AuthResultData(String accessToken, String sessionId) {
this.accessToken = accessToken;
this.sessionId = sessionId;
}

public String getAccessToken() {
return accessToken;
}

public String getSessionId() {
return sessionId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.tuna.zoopzoop.backend.domain.auth.entity;

import org.springframework.stereotype.Component;
import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class AuthResult {
private final Map<String, AuthResultData> results = new ConcurrentHashMap<>();

public void put(String state, String accessToken, String sessionId) {
results.put(state, new AuthResultData(accessToken, sessionId));
}

public AuthResultData get(String state) {
return results.remove(state);
}

public void consume(String state) {
results.remove(state);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.tuna.zoopzoop.backend.domain.auth.entity;

import jakarta.persistence.*;
import lombok.*;
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity;

import java.time.LocalDateTime;

@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RefreshToken extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", unique = true, nullable = false)
private Member member;

@Column(name = "session_id", unique = true, nullable = false)
private String sessionId;

@Column(unique = true, nullable = false)
private String refreshToken;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "expired_at")
private LocalDateTime expiredAt;

@PrePersist
public void prePersist() {
if(createdAt == null) {
this.createdAt = LocalDateTime.now();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package org.tuna.zoopzoop.backend.domain.auth.global;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;

import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {

private final OAuth2AuthorizationRequestResolver defaultResolver;
Expand All @@ -28,13 +33,25 @@ private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest req, Htt
if (req == null) return null;

String source = request.getParameter("source"); // 로그인 시작 시 전달된 source

OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.from(req);

if ("extension".equals(source)) {
// state에 source 정보를 안전하게 포함
builder.state("source:extension;" + req.getState());
String state = request.getParameter("state");
Map<String, String> stateData = new HashMap<>();
stateData.put("source", "extension");
stateData.put("customState", state);
stateData.put("originalState", req.getState());

try {
String encodedState = Base64.getUrlEncoder()
.encodeToString(new ObjectMapper().writeValueAsBytes(stateData));
builder.state(encodedState);
} catch (Exception e) {
e.printStackTrace();
return builder.build();
}
}

return builder.build();
}
}
Loading