From 51bf619618420730140dba8995e42c5c5407f9fe Mon Sep 17 00:00:00 2001 From: Seol-JY Date: Mon, 23 Feb 2026 20:49:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=B9=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QR 코드 기반 웹 로그인 세션 관리 (Redis) - 웹용 Access Token 발급 (3일 만료) - CORS에 web.toduck.app 도메인 추가 - 웹 로그인 인증 엔드포인트 보안 설정 Co-Authored-By: Claude Opus 4.6 --- build.gradle | 4 + .../auth/domain/service/JwtService.java | 7 ++ .../auth/domain/usecase/WebLoginUseCase.java | 105 ++++++++++++++++++ .../auth/presentation/api/WebLoginApi.java | 68 ++++++++++++ .../controller/WebLoginController.java | 71 ++++++++++++ .../dto/request/WebLoginAuthorizeRequest.java | 12 ++ .../response/WebSessionCreateResponse.java | 15 +++ .../response/WebSessionStatusResponse.java | 38 +++++++ .../global/config/security/CorsConfig.java | 14 ++- .../config/security/SecurityConfig.java | 2 + .../jwt/access/AccessTokenProvider.java | 12 ++ .../infra/redis/weblogin/WebLoginSession.java | 66 +++++++++++ .../weblogin/WebLoginSessionRepository.java | 6 + .../weblogin/WebLoginSessionService.java | 34 ++++++ .../weblogin/WebLoginSessionServiceImpl.java | 46 ++++++++ .../redis/weblogin/WebLoginSessionStatus.java | 7 ++ 16 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/main/java/im/toduck/domain/auth/domain/usecase/WebLoginUseCase.java create mode 100644 src/main/java/im/toduck/domain/auth/presentation/api/WebLoginApi.java create mode 100644 src/main/java/im/toduck/domain/auth/presentation/controller/WebLoginController.java create mode 100644 src/main/java/im/toduck/domain/auth/presentation/dto/request/WebLoginAuthorizeRequest.java create mode 100644 src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionCreateResponse.java create mode 100644 src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionStatusResponse.java create mode 100644 src/main/java/im/toduck/infra/redis/weblogin/WebLoginSession.java create mode 100644 src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionRepository.java create mode 100644 src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionService.java create mode 100644 src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionServiceImpl.java create mode 100644 src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionStatus.java diff --git a/build.gradle b/build.gradle index 80a7c104..d2a50978 100644 --- a/build.gradle +++ b/build.gradle @@ -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 설정 diff --git a/src/main/java/im/toduck/domain/auth/domain/service/JwtService.java b/src/main/java/im/toduck/domain/auth/domain/service/JwtService.java index 3b131c97..c76c6f83 100644 --- a/src/main/java/im/toduck/domain/auth/domain/service/JwtService.java +++ b/src/main/java/im/toduck/domain/auth/domain/service/JwtService.java @@ -71,6 +71,13 @@ public Pair 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) { diff --git a/src/main/java/im/toduck/domain/auth/domain/usecase/WebLoginUseCase.java b/src/main/java/im/toduck/domain/auth/domain/usecase/WebLoginUseCase.java new file mode 100644 index 00000000..71b48c4e --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/domain/usecase/WebLoginUseCase.java @@ -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); + } + } +} diff --git a/src/main/java/im/toduck/domain/auth/presentation/api/WebLoginApi.java b/src/main/java/im/toduck/domain/auth/presentation/api/WebLoginApi.java new file mode 100644 index 00000000..6f7d4a1c --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/presentation/api/WebLoginApi.java @@ -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> createWebSession(); + + @Operation( + summary = "웹 로그인 승인", + description = "iOS 앱에서 QR 코드를 스캔한 후 로그인을 승인합니다. " + + "인증된 사용자만 호출할 수 있습니다. " + + "세션이 만료되었거나 이미 승인된 경우에도 성공 응답을 반환합니다 (idempotent)." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "승인 요청이 처리되었습니다. authorized 필드로 실제 승인 여부를 확인할 수 있습니다." + ) + ) + ResponseEntity> 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> getWebSessionStatus( + @PathVariable String sessionToken + ); +} diff --git a/src/main/java/im/toduck/domain/auth/presentation/controller/WebLoginController.java b/src/main/java/im/toduck/domain/auth/presentation/controller/WebLoginController.java new file mode 100644 index 00000000..5dc8fc06 --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/presentation/controller/WebLoginController.java @@ -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> createWebSession() { + WebSessionCreateResponse response = webLoginUseCase.createWebSession(); + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @PostMapping("/authorize") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> 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> getWebSessionStatus( + @PathVariable final String sessionToken + ) { + WebSessionStatusResponse response = webLoginUseCase.getWebSessionStatus(sessionToken); + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } +} diff --git a/src/main/java/im/toduck/domain/auth/presentation/dto/request/WebLoginAuthorizeRequest.java b/src/main/java/im/toduck/domain/auth/presentation/dto/request/WebLoginAuthorizeRequest.java new file mode 100644 index 00000000..a8df42ac --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/presentation/dto/request/WebLoginAuthorizeRequest.java @@ -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 +) { +} diff --git a/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionCreateResponse.java b/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionCreateResponse.java new file mode 100644 index 00000000..a423ab78 --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionCreateResponse.java @@ -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 +) { +} diff --git a/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionStatusResponse.java b/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionStatusResponse.java new file mode 100644 index 00000000..6b7b696f --- /dev/null +++ b/src/main/java/im/toduck/domain/auth/presentation/dto/response/WebSessionStatusResponse.java @@ -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(); + } +} diff --git a/src/main/java/im/toduck/global/config/security/CorsConfig.java b/src/main/java/im/toduck/global/config/security/CorsConfig.java index 5dfb0740..7656dc57 100644 --- a/src/main/java/im/toduck/global/config/security/CorsConfig.java +++ b/src/main/java/im/toduck/global/config/security/CorsConfig.java @@ -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)); diff --git a/src/main/java/im/toduck/global/config/security/SecurityConfig.java b/src/main/java/im/toduck/global/config/security/SecurityConfig.java index bb65bd5f..2686a014 100644 --- a/src/main/java/im/toduck/global/config/security/SecurityConfig.java +++ b/src/main/java/im/toduck/global/config/security/SecurityConfig.java @@ -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; @@ -73,6 +74,7 @@ .AuthorizedUrl> defaultAuthorizeHt AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { return auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers(AUTHENTICATED_AUTH_ENDPOINTS).authenticated() .requestMatchers(ANONYMOUS_ENDPOINTS).anonymous(); } } diff --git a/src/main/java/im/toduck/global/security/jwt/access/AccessTokenProvider.java b/src/main/java/im/toduck/global/security/jwt/access/AccessTokenProvider.java index 6187f97e..0bc8a6ca 100644 --- a/src/main/java/im/toduck/global/security/jwt/access/AccessTokenProvider.java +++ b/src/main/java/im/toduck/global/security/jwt/access/AccessTokenProvider.java @@ -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); diff --git a/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSession.java b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSession.java new file mode 100644 index 00000000..b48f7027 --- /dev/null +++ b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSession.java @@ -0,0 +1,66 @@ +package im.toduck.infra.redis.weblogin; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@RedisHash("webLoginSession") +@Getter +@ToString(of = {"sessionToken", "status", "approvedUserId"}) +@EqualsAndHashCode(of = {"sessionToken"}) +public class WebLoginSession { + private static final long DEFAULT_TTL_SECONDS = 300; + + @Id + private String sessionToken; + private WebLoginSessionStatus status; + private Long approvedUserId; + private String approvedUserRole; + + @TimeToLive + private long ttl = DEFAULT_TTL_SECONDS; + + protected WebLoginSession() { + } + + @Builder + private WebLoginSession( + final String sessionToken, + final WebLoginSessionStatus status, + final Long approvedUserId, + final String approvedUserRole, + final Long ttl + ) { + this.sessionToken = sessionToken; + this.status = status; + this.approvedUserId = approvedUserId; + this.approvedUserRole = approvedUserRole; + this.ttl = (ttl != null) ? ttl : DEFAULT_TTL_SECONDS; + } + + public static WebLoginSession createPending(final String sessionToken) { + return WebLoginSession.builder() + .sessionToken(sessionToken) + .status(WebLoginSessionStatus.PENDING) + .build(); + } + + public void approve(final Long userId, final String role) { + this.status = WebLoginSessionStatus.APPROVED; + this.approvedUserId = userId; + this.approvedUserRole = role; + } + + public boolean isPending() { + return this.status == WebLoginSessionStatus.PENDING; + } + + public boolean isApproved() { + return this.status == WebLoginSessionStatus.APPROVED; + } +} diff --git a/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionRepository.java b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionRepository.java new file mode 100644 index 00000000..c3cda87a --- /dev/null +++ b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionRepository.java @@ -0,0 +1,6 @@ +package im.toduck.infra.redis.weblogin; + +import org.springframework.data.repository.CrudRepository; + +public interface WebLoginSessionRepository extends CrudRepository { +} diff --git a/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionService.java b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionService.java new file mode 100644 index 00000000..c707e2da --- /dev/null +++ b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionService.java @@ -0,0 +1,34 @@ +package im.toduck.infra.redis.weblogin; + +import java.util.Optional; + +public interface WebLoginSessionService { + /** + * 새로운 웹 로그인 세션을 생성하고 저장합니다. + * + * @return 생성된 {@link WebLoginSession} + */ + WebLoginSession createSession(); + + /** + * 세션 토큰으로 웹 로그인 세션을 조회합니다. + * + * @param sessionToken 세션 토큰 + * @return {@link Optional} of {@link WebLoginSession} + */ + Optional findBySessionToken(String sessionToken); + + /** + * 세션을 삭제합니다. (토큰 발급 후 일회용으로 삭제) + * + * @param sessionToken 세션 토큰 + */ + void deleteSession(String sessionToken); + + /** + * 세션을 저장합니다. + * + * @param session 저장할 세션 + */ + void save(WebLoginSession session); +} diff --git a/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionServiceImpl.java b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionServiceImpl.java new file mode 100644 index 00000000..3c05c9f2 --- /dev/null +++ b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionServiceImpl.java @@ -0,0 +1,46 @@ +package im.toduck.infra.redis.weblogin; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WebLoginSessionServiceImpl implements WebLoginSessionService { + private static final int TOKEN_BYTE_LENGTH = 36; // 48자 Base64 결과 + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final WebLoginSessionRepository webLoginSessionRepository; + + @Override + public WebLoginSession createSession() { + String sessionToken = generateSecureToken(); + WebLoginSession session = WebLoginSession.createPending(sessionToken); + return webLoginSessionRepository.save(session); + } + + @Override + public Optional findBySessionToken(final String sessionToken) { + return webLoginSessionRepository.findById(sessionToken); + } + + @Override + public void deleteSession(final String sessionToken) { + webLoginSessionRepository.deleteById(sessionToken); + } + + @Override + public void save(final WebLoginSession session) { + webLoginSessionRepository.save(session); + } + + private String generateSecureToken() { + byte[] randomBytes = new byte[TOKEN_BYTE_LENGTH]; + SECURE_RANDOM.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } +} diff --git a/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionStatus.java b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionStatus.java new file mode 100644 index 00000000..800f9013 --- /dev/null +++ b/src/main/java/im/toduck/infra/redis/weblogin/WebLoginSessionStatus.java @@ -0,0 +1,7 @@ +package im.toduck.infra.redis.weblogin; + +public enum WebLoginSessionStatus { + PENDING, + APPROVED, + EXPIRED +}