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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ dependencies {
/* actuator - prometheus */
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

/* QR Code Generation (ZXing) */
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.google.zxing:javase:3.5.3'
}

// QueryDSL 설정
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ public Pair<Long, JwtPair> refresh(String refreshToken) {
return Pair.of(userId, JwtPair.of(newAccessToken, newRefreshToken.getToken()));
}

public String createWebAccessToken(final Long userId, final String role) {
return accessTokenProvider.generateTokenWithCustomExpiry(
AccessTokenClaim.of(userId, role),
Duration.ofDays(3)
);
}

public void removeAccessTokenAndRefreshToken(Long userId, String accessToken, String refreshToken) {
JwtClaims jwtClaims = null;
if (refreshToken != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package im.toduck.domain.auth.domain.usecase;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;

import javax.imageio.ImageIO;

import org.springframework.transaction.annotation.Transactional;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;

import im.toduck.domain.auth.domain.service.JwtService;
import im.toduck.domain.auth.presentation.dto.request.WebLoginAuthorizeRequest;
import im.toduck.domain.auth.presentation.dto.response.WebSessionCreateResponse;
import im.toduck.domain.auth.presentation.dto.response.WebSessionStatusResponse;
import im.toduck.global.annotation.UseCase;
import im.toduck.infra.redis.weblogin.WebLoginSession;
import im.toduck.infra.redis.weblogin.WebLoginSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@UseCase
@RequiredArgsConstructor
public class WebLoginUseCase {
private static final String UNIVERSAL_LINK_BASE = "https://toduck.app/_ul/w/";
private static final int QR_CODE_SIZE = 300;

private final WebLoginSessionService webLoginSessionService;
private final JwtService jwtService;

@Transactional
public WebSessionCreateResponse createWebSession() {
WebLoginSession session = webLoginSessionService.createSession();
String qrCodeUrl = UNIVERSAL_LINK_BASE + session.getSessionToken();
String qrImageBase64 = generateQrCodeBase64(qrCodeUrl);

log.info("웹 로그인 세션 생성 - SessionToken: {}", session.getSessionToken());

return WebSessionCreateResponse.builder()
.sessionToken(session.getSessionToken())
.qrImageBase64(qrImageBase64)
.build();
}

@Transactional
public boolean authorizeWebSession(final Long userId, final String role, final WebLoginAuthorizeRequest request) {
return webLoginSessionService.findBySessionToken(request.sessionToken())
.map(session -> {
session.approve(userId, role);
webLoginSessionService.save(session);
log.info("웹 로그인 세션 승인 - UserId: {}, SessionToken: {}", userId, request.sessionToken());
return true;
})
.orElseGet(() -> {
log.info("웹 로그인 세션 없음/만료 - UserId: {}, SessionToken: {}", userId, request.sessionToken());
return false;
});
}

@Transactional
public WebSessionStatusResponse getWebSessionStatus(final String sessionToken) {
return webLoginSessionService.findBySessionToken(sessionToken)
.map(session -> {
if (session.isApproved()) {
String accessToken = jwtService.createWebAccessToken(
session.getApprovedUserId(),
session.getApprovedUserRole()
);
webLoginSessionService.deleteSession(sessionToken);
log.info("웹 로그인 토큰 발급 완료 - UserId: {}, SessionToken: {}",
session.getApprovedUserId(), sessionToken);
return WebSessionStatusResponse.approved(accessToken, session.getApprovedUserId());
}
return WebSessionStatusResponse.pending();
})
.orElseGet(() -> {
log.info("웹 로그인 세션 만료 또는 미존재 - SessionToken: {}", sessionToken);
return WebSessionStatusResponse.expired();
});
}

private String generateQrCodeBase64(final String content) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_SIZE, QR_CODE_SIZE);
BufferedImage qrImage = MatrixToImageWriter.toBufferedImage(bitMatrix);

ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(qrImage, "PNG", outputStream);
byte[] imageBytes = outputStream.toByteArray();

return "data:image/png;base64," + Base64.getEncoder().encodeToString(imageBytes);
} catch (WriterException | IOException e) {
log.error("QR 코드 생성 실패", e);
throw new RuntimeException("QR 코드 생성 중 오류가 발생했습니다.", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package im.toduck.domain.auth.presentation.api;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

import im.toduck.domain.auth.presentation.dto.request.WebLoginAuthorizeRequest;
import im.toduck.domain.auth.presentation.dto.response.WebSessionCreateResponse;
import im.toduck.domain.auth.presentation.dto.response.WebSessionStatusResponse;
import im.toduck.global.annotation.swagger.ApiResponseExplanations;
import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation;
import im.toduck.global.presentation.ApiResponse;
import im.toduck.global.security.authentication.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

@Tag(name = "Web Login", description = "QR 코드 기반 웹 로그인 API")
public interface WebLoginApi {

@Operation(
summary = "웹 로그인 세션 생성",
description = "QR 코드를 포함한 웹 로그인 세션을 생성합니다. "
+ "웹 브라우저에서 호출하며, 반환된 QR 코드를 화면에 표시합니다. "
+ "세션은 5분간 유효합니다."
)
@ApiResponseExplanations(
success = @ApiSuccessResponseExplanation(
responseClass = WebSessionCreateResponse.class,
description = "세션 토큰, QR 코드 이미지(Base64), 유니버셜 링크, 만료 시간이 반환됩니다."
)
)
ResponseEntity<ApiResponse<WebSessionCreateResponse>> createWebSession();

@Operation(
summary = "웹 로그인 승인",
description = "iOS 앱에서 QR 코드를 스캔한 후 로그인을 승인합니다. "
+ "인증된 사용자만 호출할 수 있습니다. "
+ "세션이 만료되었거나 이미 승인된 경우에도 성공 응답을 반환합니다 (idempotent)."
)
@ApiResponseExplanations(
success = @ApiSuccessResponseExplanation(
description = "승인 요청이 처리되었습니다. authorized 필드로 실제 승인 여부를 확인할 수 있습니다."
)
)
ResponseEntity<ApiResponse<?>> authorizeWebSession(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody @Valid WebLoginAuthorizeRequest request
);

@Operation(
summary = "웹 로그인 세션 상태 확인",
description = "웹 브라우저에서 세션 상태를 폴링하여 확인합니다. "
+ "PENDING: 대기 중, APPROVED: 승인됨 (토큰 발급), EXPIRED: 만료됨 (새 QR 발급 필요)"
)
@ApiResponseExplanations(
success = @ApiSuccessResponseExplanation(
responseClass = WebSessionStatusResponse.class,
description = "세션 상태와 승인 시 액세스 토큰이 반환됩니다. "
+ "APPROVED 상태일 때 accessToken과 userId가 포함됩니다. "
+ "토큰 발급 후 세션은 자동 삭제됩니다."
)
)
ResponseEntity<ApiResponse<WebSessionStatusResponse>> getWebSessionStatus(
@PathVariable String sessionToken
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package im.toduck.domain.auth.presentation.controller;

import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import im.toduck.domain.auth.domain.usecase.WebLoginUseCase;
import im.toduck.domain.auth.presentation.api.WebLoginApi;
import im.toduck.domain.auth.presentation.dto.request.WebLoginAuthorizeRequest;
import im.toduck.domain.auth.presentation.dto.response.WebSessionCreateResponse;
import im.toduck.domain.auth.presentation.dto.response.WebSessionStatusResponse;
import im.toduck.global.presentation.ApiResponse;
import im.toduck.global.security.authentication.CustomUserDetails;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/auth/web")
public class WebLoginController implements WebLoginApi {
private final WebLoginUseCase webLoginUseCase;

@Override
@PostMapping("/sessions")
@PreAuthorize("isAnonymous()")
public ResponseEntity<ApiResponse<WebSessionCreateResponse>> createWebSession() {
WebSessionCreateResponse response = webLoginUseCase.createWebSession();
return ResponseEntity.ok(ApiResponse.createSuccess(response));
}

@Override
@PostMapping("/authorize")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<?>> authorizeWebSession(
@AuthenticationPrincipal final CustomUserDetails userDetails,
@RequestBody @Valid final WebLoginAuthorizeRequest request
) {
String role = extractRoleFromAuthorities(userDetails);
boolean authorized = webLoginUseCase.authorizeWebSession(
userDetails.getUserId(),
role,
request
);
return ResponseEntity.ok(ApiResponse.createSuccess(Map.of("authorized", authorized)));
}

private String extractRoleFromAuthorities(final CustomUserDetails userDetails) {
return userDetails.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse("USER");
}

@Override
@GetMapping("/sessions/{sessionToken}")
@PreAuthorize("isAnonymous()")
public ResponseEntity<ApiResponse<WebSessionStatusResponse>> getWebSessionStatus(
@PathVariable final String sessionToken
) {
WebSessionStatusResponse response = webLoginUseCase.getWebSessionStatus(sessionToken);
return ResponseEntity.ok(ApiResponse.createSuccess(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package im.toduck.domain.auth.presentation.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "웹 로그인 승인 요청 DTO")
public record WebLoginAuthorizeRequest(
@NotBlank(message = "세션 토큰은 필수입니다.")
@Schema(description = "QR 코드에서 추출한 세션 토큰", example = "abc123xyz...")
String sessionToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package im.toduck.domain.auth.presentation.dto.response;

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

@Schema(description = "웹 로그인 세션 생성 응답 DTO")
@Builder
public record WebSessionCreateResponse(
@Schema(description = "세션 토큰 (폴링 시 사용)", example = "abc123xyz...")
String sessionToken,

@Schema(description = "QR 코드 PNG 이미지 (Base64 인코딩)", example = "data:image/png;base64,iVBORw0KGgo...")
String qrImageBase64
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package im.toduck.domain.auth.presentation.dto.response;

import im.toduck.infra.redis.weblogin.WebLoginSessionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Schema(description = "웹 로그인 세션 상태 확인 응답 DTO")
@Builder
public record WebSessionStatusResponse(
@Schema(description = "세션 상태 (PENDING: 대기중, APPROVED: 승인됨, EXPIRED: 만료됨)", example = "APPROVED")
WebLoginSessionStatus status,

@Schema(description = "액세스 토큰 (승인된 경우에만 포함)", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
String accessToken,

@Schema(description = "사용자 ID (승인된 경우에만 포함)", example = "1")
Long userId
) {
public static WebSessionStatusResponse pending() {
return WebSessionStatusResponse.builder()
.status(WebLoginSessionStatus.PENDING)
.build();
}

public static WebSessionStatusResponse approved(final String accessToken, final Long userId) {
return WebSessionStatusResponse.builder()
.status(WebLoginSessionStatus.APPROVED)
.accessToken(accessToken)
.userId(userId)
.build();
}

public static WebSessionStatusResponse expired() {
return WebSessionStatusResponse.builder()
.status(WebLoginSessionStatus.EXPIRED)
.build();
}
}
14 changes: 11 additions & 3 deletions src/main/java/im/toduck/global/config/security/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ public class CorsConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(
List.of("https://api.toduck.app", "https://toduck.app", "https://backoffice.toduck.app",
"https://dev-api-toduck.seol.pro", "https://api-toduck.seol.pro",
"https://backoffice-toduck.seol.pro", "http://localhost:5173"));
List.of(
"https://api.toduck.app",
"https://toduck.app",
"https://web.toduck.app",
"https://backoffice.toduck.app",
"https://dev-api-toduck.seol.pro",
"https://api-toduck.seol.pro",
"https://backoffice-toduck.seol.pro",
"http://localhost:5173"
)
);
configuration.setAllowedMethods(List.of("GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.SET_COOKIE));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class SecurityConfig {
"/exception-codes"};
private static final String[] PUBLIC_ENDPOINTS = {"/", "/error", "v1/backoffice/app/version-check"};
private static final String[] ANONYMOUS_ENDPOINTS = {"/v1/auth/**", "/v1/users/find/**"};
private static final String[] AUTHENTICATED_AUTH_ENDPOINTS = {"/v1/auth/web/authorize"};
private static final String[] ATUATOR_ENDPOINTS = {"/actuator/**"};

private final CorsConfigurationSource corsConfigurationSource;
Expand Down Expand Up @@ -73,6 +74,7 @@ <AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizedUrl> defaultAuthorizeHt
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth) {
return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(AUTHENTICATED_AUTH_ENDPOINTS).authenticated()
.requestMatchers(ANONYMOUS_ENDPOINTS).anonymous();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ public String generateToken(JwtClaims claims) {
.compact();
}

public String generateTokenWithCustomExpiry(JwtClaims claims, Duration customExpiry) {
Date now = new Date();

return Jwts.builder()
.header().add(createHeader())
.and()
.claims(claims.getClaims())
.signWith(secretKey)
.expiration(createExpireDate(now, customExpiry.toMillis()))
.compact();
}

@Override
public JwtClaims getJwtClaimsFromToken(String token) {
Claims claims = getClaimsFromToken(token);
Expand Down
Loading
Loading