Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
43bf093
feat: 레포 생성 (#42)
kimyw1018 Jul 24, 2026
670d712
feat: Oauth 로직 구현 (#42)
kimyw1018 Jul 24, 2026
26e1c8a
feat:최신상채 반영 (#42)
kimyw1018 Jul 24, 2026
990e644
Merge branch 'develop' of https://github.com/Musereview/BE into feat/…
kimyw1018 Jul 27, 2026
838133b
feat: auth operation 추가 (#42)
kimyw1018 Jul 27, 2026
4a2f0e3
feat: DTO 파일 위치 변경(#42)
kimyw1018 Jul 27, 2026
90241bb
feat: 소셜 로그인 연동(#42)
kimyw1018 Jul 27, 2026
f09ca1f
feat: 코드 컨밴션 맞춰 수정(#42)
kimyw1018 Jul 27, 2026
b7a362a
feat: 유저 연동(#42)
kimyw1018 Jul 27, 2026
40b4527
feat: 유저 연동(#42)
kimyw1018 Jul 27, 2026
9c26a57
feat: 로그인, 회원가입 api 완성(#42)
kimyw1018 Jul 27, 2026
5f1bf67
feat: 리프래시 토큰 api 구현(#42)
kimyw1018 Jul 27, 2026
12c45bc
feat: 로그아웃 api 구현(#42)
kimyw1018 Jul 27, 2026
e834072
feat: 회원 탈퇴 api 구현(#42)
kimyw1018 Jul 27, 2026
503d89e
feat: UserId -> User_UserId로 변경(#42)
kimyw1018 Jul 27, 2026
65eb092
feat: PUBLIC_URLS에 /api/auth/reissue 등록(#42)
kimyw1018 Jul 27, 2026
d952845
feat: 신규 가입 여부 추가 (#42)
kimyw1018 Jul 28, 2026
867caa2
feat: OAuth 클라이언트 타임아웃 (#42)
kimyw1018 Jul 28, 2026
5d2aed4
feat: OAuth 서비스 객체 생성 정상 동작 검증 (#42)
kimyw1018 Jul 28, 2026
ac7e912
feat: socialId 검증 로직 (#42)
kimyw1018 Jul 28, 2026
19c7bd0
feat: 프로필 이미지 변수 설정 (#42)
kimyw1018 Jul 28, 2026
ec853a0
chore: remove tracked application.yml and application.example.yml fro…
kimyw1018 Jul 28, 2026
4006ee9
feat: 한 유저가 여러 소셜 auth를 가짐(#42)
kimyw1018 Jul 28, 2026
dec0289
feat: 토큰 해시값만 저장하도록 수정(#42)
kimyw1018 Jul 28, 2026
4dd0fa7
feat: 토큰 단위 테스트 (#42)
kimyw1018 Jul 28, 2026
cdc38d5
feat: 동시성 락 (#42)
kimyw1018 Jul 28, 2026
7c465ca
feat: 토큰 재발급 시간 보장 (#42)
kimyw1018 Jul 28, 2026
bd6d519
feat: 불필요한 토큰 발급정리 (#42)
kimyw1018 Jul 28, 2026
64b49f9
feat: 소셜 사용자 정보 요청은 트랜젝셕 외부로 이동(#42)
kimyw1018 Jul 28, 2026
9ab33ff
feat: 동시 가입 요청시 제약조건, 예외처리(#42)
kimyw1018 Jul 28, 2026
634bccb
feat: 문서 재정의 (#42)
kimyw1018 Jul 28, 2026
d6ba3c0
feat: 로그아웃시 해당 기기에 한함 (#42)
kimyw1018 Jul 28, 2026
12aad78
feat: 상태, 에러코드 구분 (#42)
kimyw1018 Jul 28, 2026
029a1ee
feat: 401, 500 검증 로직 (#42)
kimyw1018 Jul 28, 2026
22b11bc
feat: provider별 dto 추가 (#42)
kimyw1018 Jul 28, 2026
cf0788f
feat: 타입 대체 안정성 반영 (#42)
kimyw1018 Jul 28, 2026
660efa3
feat: 불필요 헤더 정리 (#42)
kimyw1018 Jul 28, 2026
5336c26
feat: 설정 분리 (#42)
kimyw1018 Jul 28, 2026
189cf55
feat: getUserIdFromToken추가 (#42)
kimyw1018 Jul 28, 2026
1be422d
feat: getUserIdFromToken사용으로 수정 (#42)
kimyw1018 Jul 28, 2026
f13ce01
feat: uuid 적용 (#42)
kimyw1018 Jul 28, 2026
92027d5
feat: reissueToken 시 expiredAt 누락 (#42)
kimyw1018 Jul 28, 2026
363a09c
feat: JWT iat, exp타임존 제거 (#42)
kimyw1018 Jul 28, 2026
894c00c
feat: 로그아웃 시 연결 기기 초기화 (#42)
kimyw1018 Jul 28, 2026
d793aa9
feat: RuntimeException-> IllegalStateException (#42)
kimyw1018 Jul 28, 2026
cfa6b37
feat: 유저아이디 직접 호출 (#42)
kimyw1018 Jul 28, 2026
2af091b
feat: 불필요 앤드포인트 정리 (#42)
kimyw1018 Jul 28, 2026
2a3f24e
feat: 문자영 잘림 헬퍼 (#42)
kimyw1018 Jul 28, 2026
ad6c4f0
feat: mapper 파일 분기 (#42)
kimyw1018 Jul 28, 2026
18d69dd
feat: 소셜계정 연동 (#42)
kimyw1018 Jul 28, 2026
b274c10
feat: 목데이터 호출 검증 (#42)
kimyw1018 Jul 28, 2026
17e4dc8
feat: 테스트 코드 세분화 (#42)
kimyw1018 Jul 28, 2026
fc257ef
feat: 테스트 케이스 토큰 전송 검증 (#42)
kimyw1018 Jul 28, 2026
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
72 changes: 66 additions & 6 deletions src/main/java/com/mr/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,35 +1,95 @@
package com.mr.domain.auth.controller;

import com.mr.domain.auth.dto.AuthRequestDTO;
import com.mr.domain.auth.dto.AuthResponseDTO;
import com.mr.domain.auth.dto.req.AuthRequestDTO;
import com.mr.domain.auth.dto.res.AuthResponseDTO;
import com.mr.domain.auth.entity.enums.SocialType;
import com.mr.domain.auth.service.AuthService;
import com.mr.global.apipayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.mr.global.security.SecurityUtil;

@Tag(name = "Auth API", description = "인증 및 소셜 로그인 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@Profile({"local", "dev"})
public class AuthController {

private final AuthService authService;

@SecurityRequirements
@Operation(
summary = "소셜 로그인 / 회원가입 API",
description = "카카오 및 구글 OAuth Access Token을 받아 로그인을 진행하고, 서비스 전용 JWT 토큰을 발급합니다."
)
@PostMapping("/login/{socialType}")
public ApiResponse<AuthResponseDTO.LoginResponse> socialLogin(
@Parameter(description = "소셜 로그인 제공자 (KAKAO, GOOGLE)", example = "KAKAO")
@PathVariable(name = "socialType") SocialType socialType,
@RequestBody @Valid AuthRequestDTO.SocialLoginRequest request
@RequestBody @Valid AuthRequestDTO.SocialLoginRequest request,
@RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo
) {
AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken());
AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo);
return ApiResponse.onSuccess(response);
}
@SecurityRequirements
@Operation(summary = "토큰 재발급 API", description = "만료된 Access Token을 Refresh Token을 이용해 재발급합니다.")
@PostMapping("/reissue")
Comment thread
kimyw1018 marked this conversation as resolved.
public ApiResponse<AuthResponseDTO.TokenInfo> reissue(
@RequestBody @Valid AuthRequestDTO.TokenRefreshRequest request
) {
AuthResponseDTO.TokenInfo tokenInfo = authService.reissueToken(request.refreshToken());
return ApiResponse.onSuccess(tokenInfo);
}
Comment thread
kimyw1018 marked this conversation as resolved.

@Operation(
summary = "소셜 계정 추가 연동 API",
description = "현재 로그인된 사용자의 계정에 새로운 소셜 계정(카카오/구글)을 추가로 연동합니다."
)
@PostMapping("/link/{socialType}")
public ApiResponse<AuthResponseDTO.TokenInfo> linkSocialAccount(
@Parameter(description = "연동할 소셜 제공자 (KAKAO, GOOGLE)", example = "GOOGLE")
@PathVariable(name = "socialType") SocialType socialType,
@RequestBody @Valid AuthRequestDTO.SocialLoginRequest request,
@RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo
) {
Long userId = SecurityUtil.getCurrentUserId();
AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(userId, socialType, request.accessToken(), deviceInfo);
return ApiResponse.onSuccess(tokenInfo);
}

@Operation(
summary = "로그아웃 API",
description = "현재 요청 기기의 Refresh Token 세션을 선택적으로 만료 처리합니다."
)
@PostMapping("/logout")
public ApiResponse<Void> logout(
@RequestBody @Valid AuthRequestDTO.LogoutRequest request
) {
Long userId = SecurityUtil.getCurrentUserId();
authService.logout(userId, request.refreshToken());
return ApiResponse.onSuccess(null);
}

@Operation(
summary = "회원 탈퇴 API",
description = "사용자 계정을 탈퇴 처리하고 저장된 소셜 인증 정보 및 Refresh Token 세션을 완전히 삭제합니다."
)
@PostMapping("/withdraw")
public ApiResponse<Void> withdraw() {
Long userId = SecurityUtil.getCurrentUserId();
authService.withdraw(userId);
return ApiResponse.onSuccess(null);
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.mr.domain.auth.dto;

import lombok.Builder;

@Builder
public record OAuthUserInfo(
String socialId,
String profileImgUrl
) {}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mr.domain.auth.dto;
package com.mr.domain.auth.dto.req;

import jakarta.validation.constraints.NotBlank;

Expand All @@ -13,4 +13,9 @@ public record TokenRefreshRequest(
@NotBlank(message = "Refresh Token은 필수 입력값입니다.")
String refreshToken
) {}

public record LogoutRequest(
@NotBlank(message = "Refresh Token은 필수 입력값입니다.")
String refreshToken
) {}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mr.domain.auth.dto;
package com.mr.domain.auth.dto.res;

import lombok.Builder;

Expand All @@ -11,11 +11,19 @@ public record TokenResponse(
Long accessTokenExpiresInSeconds
) {}

@Builder
public record TokenInfo(
String accessToken,
String refreshToken,
Long accessTokenExpiresInSeconds
) {}

@Builder
public record LoginResponse(
Long userId,
String nickname,
boolean isNewUser,
boolean isOnboardingCompleted,
TokenResponse tokenInfo
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.mr.domain.auth.dto.res;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleUserResponse(
String id,
String picture,
String email
) {}
18 changes: 18 additions & 0 deletions src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mr.domain.auth.dto.res;

import com.fasterxml.jackson.annotation.JsonProperty;

public record KakaoUserResponse(
Long id,
@JsonProperty("kakao_account")
KakaoAccount kakaoAccount
) {
public record KakaoAccount(
Profile profile
) {}

public record Profile(
@JsonProperty("profile_image_url")
String profileImageUrl
) {}
}
77 changes: 46 additions & 31 deletions src/main/java/com/mr/domain/auth/entity/SocialAuth.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 소셜 계정 매핑 정보와 서비스 Refresh Token 세션 정보를 동시에 관리하고 있는 걸로 보이는데, 이 구조에서는 같은 사용자가 다른 기기에서 로그인할 때 기존 Refresh Token이 덮어써져 기존 기기의 세션이 자동으로 만료될 것 같아요! 사용자당 단일 세션 정책이 의도된 것인지 궁금합니다~

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뮤즈리뷰가 멀티 디바이스 지원이 필요하다 생각하지 않아서 단일 세션으로 구현했는데 이부분 논의해보면 좋을 것 같습니다!

Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
package com.mr.domain.auth.entity;

import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.domain.auth.entity.enums.SocialType;
import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.domain.user.entity.User;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.entity.BaseCreatedEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
// TODO: 추후 User 도메인 완성 시 인덱스 추가
@Table(
name = "social_auth",
uniqueConstraints = {
Expand All @@ -29,9 +28,9 @@ public class SocialAuth extends BaseCreatedEntity {
@Column(name = "social_auth_id")
private Long id;

// TODO: User연결 예정
@Column(name = "user_id", nullable = false)
private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
Comment thread
kimyw1018 marked this conversation as resolved.

@Enumerated(EnumType.STRING)
@Column(name = "social_type", nullable = false, length = 20)
Expand All @@ -40,9 +39,6 @@ public class SocialAuth extends BaseCreatedEntity {
@Column(name = "social_id", nullable = false, length = 100)
private String socialId;

@Column(name = "refresh_token", length = 1000)
private String refreshToken;

@Column(name = "refresh_token_hash", length = 64, unique = true)
private String refreshTokenHash;

Expand All @@ -53,72 +49,91 @@ public class SocialAuth extends BaseCreatedEntity {
private String deviceInfo;

@Builder(access = AccessLevel.PRIVATE)
private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken,
private SocialAuth(User user, SocialType socialType, String socialId,
String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) {

validateUserAccount(userId);
validateUserAccount(socialType);
validateUserAccount(socialId);
validateUser(user);
validateSocialType(socialType);
validateSocialId(socialId);

this.userId = userId;
this.user = user;
this.socialType = socialType;
this.socialId = socialId;
this.refreshToken = refreshToken;
this.refreshTokenHash = refreshTokenHash;
this.expiredAt = expiredAt;
this.deviceInfo = deviceInfo;
this.deviceInfo = sanitizeDeviceInfo(deviceInfo);
}

private static String sanitizeDeviceInfo(String deviceInfo) {
if (deviceInfo == null || deviceInfo.isBlank()) {
return "Unknown Device";
}
String trimmed = deviceInfo.trim();
return trimmed.length() > 255 ? trimmed.substring(0, 255) : trimmed;
}

public static SocialAuth create(Long userId, SocialType socialType, String socialId,
String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) {
public static SocialAuth create(User user, SocialType socialType, String socialId,
String tokenHash, LocalDateTime expiredAt, String deviceInfo) {

validateTokenValue(encryptedToken);
validateTokenValue(tokenHash);
validateExpiryTime(expiredAt);

return SocialAuth.builder()
.userId(userId)
.user(user)
.socialType(socialType)
.socialId(socialId)
.refreshToken(encryptedToken)
.refreshTokenHash(tokenHash)
.expiredAt(expiredAt)
.deviceInfo(deviceInfo)
.build();
}

private static void validateUserAccount(Object value) {
if (value == null || (value instanceof String && ((String) value).trim().isEmpty())) {
private static void validateUser(User user) {
if (user == null) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
}

private static void validateSocialType(SocialType socialType) {
if (socialType == null) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
}

private static void validateSocialId(String socialId) {
if (socialId == null || socialId.isBlank()) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
}

private static void validateTokenValue(String token) {
if (token == null || token.trim().isEmpty()) {
if (token == null || token.isBlank()) {
throw new GeneralException(AuthErrorStatus.TOKEN_MISSING);
}
}

private static void validateExpiryTime(LocalDateTime expiredAt) {
if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul")))) {
if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now())) {
throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY);
}
}

public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) {
validateTokenValue(encryptedToken);
public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) {
validateTokenValue(tokenHash);
validateExpiryTime(newExpiredAt);

this.refreshToken = encryptedToken;
this.refreshTokenHash = tokenHash;
this.expiredAt = newExpiredAt;
this.deviceInfo = deviceInfo;
this.deviceInfo = sanitizeDeviceInfo(deviceInfo);
}

public void expireToken() {
Comment thread
kimyw1018 marked this conversation as resolved.
this.refreshToken = null;
this.refreshTokenHash = null;
this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // null 대신 현재 시각 기록
this.deviceInfo = null;
this.expiredAt = LocalDateTime.now();
}

public boolean isExpired() {
return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now());
}
}
4 changes: 2 additions & 2 deletions src/main/java/com/mr/domain/auth/entity/enums/SocialType.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.mr.domain.auth.entity.enums;

public enum SocialType {
kakao,
google
KAKAO,
GOOGLE
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ public enum AuthErrorStatus implements BaseCode {
// 리소스 부재
SOCIAL_AUTH_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH_404_01", "해당 사용자의 소셜 인증 기록을 찾을 수 없습니다."),

// 데이터 무결성ㅇ
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에 연동되어 있는 소셜 계정입니다.");
// 데이터 무결성
ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에 연동되어 있는 소셜 계정입니다."),

// 외부 소셜 연동 오류
OAUTH_CLIENT_ERROR(HttpStatus.UNAUTHORIZED, "AUTH_401_04", "소셜 로그인 인증에 실패했거나 유효하지 않은 소셜 액세스 토큰입니다."),
OAUTH_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "AUTH_503_01", "소셜 인증 제공자(카카오/구글) 서버와의 통신에 실패했습니다.");

private final HttpStatus status;
private final String code;
Expand Down
Loading
Loading