Skip to content
Open
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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-configuration-processor'

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
Expand All @@ -29,37 +28,38 @@
public class AuthService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RedisUtil redisUtil;
private final UserConverter userConverter;
private final UserService userService;

private static final String REFRESH_TOKEN_PREFIX = "user:refresh:";

@Value("${cookie.secure}")
private boolean secure;

/**
* 일반 로그인을 처리하는 메서드
* 일반 로그인 처리
*
* <p>현재는 "이메일 + 전화번호" 조합으로 인증을 수행한다.
*
* @param loginRequest 사용자 로그인 요청 객체 (이메일, 비밀번호 포함)
* @param response 액세스 토큰과 리프레시 토큰을 담기 위한 HTTP 응답 객체
* @return 로그인한 사용자 정보가 담긴 {@link UserResponse} 객체
* @throws CustomException 이메일에 해당하는 사용자가 없을 경우 {@link AuthErrorCode#INVALID_PASSWORD}
* @throws CustomException 비밀번호가 일치하지 않을 경우 {@link AuthErrorCode#INVALID_PASSWORD}
* @param loginRequest 이메일, 전화번호를 담은 요청 DTO
* @param response 발급된 액세스/리프레시 토큰을 실어보낼 HttpServletResponse
* @return 로그인한 사용자 정보
* @throws CustomException 이메일에 해당하는 유저가 없거나, 전화번호가 일치하지 않는 경우
* {@link AuthErrorCode#INVALID_PASSWORD}
*/
public UserResponse login(UserRequest.LoginRequest loginRequest, HttpServletResponse response) {
User user = validateUserCredentials(loginRequest);
return issueTokensAndSetResponse(user, response);
}

/**
* 테스트용 로그인 계정(ID = 1)을 통해 로그인을 처리하는 메서드
* 테스트용 사용자(예: ID = 1)로 로그인 처리
*
* @param response 액세스 토큰과 리프레시 토큰을 담기 위한 HTTP 응답 객체
* @return 로그인한 테스트 사용자 정보가 담긴 {@link UserResponse} 객체
* @throws CustomException ID가 1인 테스트 사용자가 존재하지 않을 경우
* {@link AuthErrorCode#AUTHENTICATION_NOT_FOUND}
* @param response 발급된 액세스/리프레시 토큰을 실어보낼 HttpServletResponse
* @return 테스트 사용자 정보
* @throws CustomException 해당 ID의 사용자가 없을 경우 {@link UserErrorCode#USER_NOT_FOUND}
*/
public UserResponse testLogin(HttpServletResponse response) {
User user =
Expand All @@ -70,42 +70,43 @@ public UserResponse testLogin(HttpServletResponse response) {
}

/**
* 로그아웃 처리 메서드
* 로그아웃 처리
*
* <p>요청 헤더에서 액세스 토큰을 추출하여 Redis 블랙리스트에 저장하고, 리프레시 토큰을 Redis에서 삭제하여 재사용을 차단합니다.
* <p>1) 액세스 토큰을 블랙리스트에 등록<br>
* 2) Redis 에 저장된 리프레시 토큰 삭제<br> 3) 클라이언트의 refreshToken 쿠키 만료 처리
*
* @param request HTTP 요청 객체 (헤더에서 Access Token 추출용)
* @param response HTTP 응답 객체 (리프레시 쿠키 삭제용)
* @throws CustomException 액세스 토큰이 유효하지 않거나 없을 경우 {@link AuthErrorCode#INVALID_ACCESS_TOKEN}
* @param request Authorization 헤더에서 액세스 토큰을 읽기 위한 HttpServletRequest
* @param response refreshToken 쿠키 삭제를 위한 HttpServletResponse
* @throws CustomException 액세스 토큰이 없거나 유효하지 않은 경우 {@link AuthErrorCode#INVALID_ACCESS_TOKEN}
*/
public void logout(HttpServletRequest request, HttpServletResponse response) {
String accessToken = resolveAccessToken(request);
if (accessToken == null || !jwtProvider.validateToken(accessToken)) {
throw new CustomException(AuthErrorCode.INVALID_ACCESS_TOKEN);
}

// 블랙리스트 등록 (accessToken → "logout" 값, 만료시간까지)
// 1) 액세스 토큰 블랙리스트 등록 (만료 시점까지)
long expiration =
jwtProvider.extractExpiration(accessToken).getTime() - System.currentTimeMillis();
redisUtil.setData("blacklist:" + accessToken, "logout", expiration / 1000);

// refresh 토큰 Redis에서 삭제
// 2) Redis 에서 리프레시 토큰 삭제
Long userId = jwtProvider.extractUserId(accessToken);
redisUtil.deleteData("user:refresh:" + userId);
redisUtil.deleteData(REFRESH_TOKEN_PREFIX + userId);

// 쿠키에서 refreshToken 제거
// 3) 쿠키에서 refreshToken 제거
deleteRefreshTokenCookie(response);
}

/**
* 액세스 토큰 재발급 처리 메서드
* 액세스 토큰 재발급
*
* <p>쿠키에서 리프레시 토큰을 추출한 후 Redis에 저장된 토큰과 비교하여 유효성을 검증합니다. 검증에 성공하면 새로운 액세스 토큰을 생성하여 응답 헤더에
* 포함시킵니다.
* <p>1) 쿠키에서 리프레시 토큰을 읽어온 뒤 유효성 검증<br>
* 2) Redis 에 저장된 리프레시 토큰과 일치하는지 확인<br> 3) 새로운 액세스 토큰을 생성하여 Authorization 헤더에 담아 응답
*
* @param request HTTP 요청 객체 (쿠키에서 리프레시 토큰 추출용)
* @param response HTTP 응답 객체 (새로운 액세스 토큰 설정용)
* @throws CustomException 리프레시 토큰이 없거나 유효하지 않거나, 저장된 토큰과 일치하지 않는 경우
* @param request 리프레시 토큰 쿠키 확인용 HttpServletRequest
* @param response 액세스 토큰을 담아보낼 HttpServletResponse
* @throws CustomException 리프레시 토큰이 없거나, 유효하지 않거나, 저장된 값과 다를 경우
* {@link AuthErrorCode#REFRESH_TOKEN_REQUIRED}
*/
public void reissueAccessToken(HttpServletRequest request, HttpServletResponse response) {
Expand All @@ -119,17 +120,19 @@ public void reissueAccessToken(HttpServletRequest request, HttpServletResponse r
Long userId = jwtProvider.extractUserId(refreshToken);

// 3. Redis에 저장된 리프레시 토큰과 비교
String storedToken = redisUtil.getData("user:refresh:" + userId);
String storedToken = redisUtil.getData(REFRESH_TOKEN_PREFIX + userId);
if (!refreshToken.equals(storedToken)) {
throw new CustomException(AuthErrorCode.REFRESH_TOKEN_REQUIRED);
}

// 4. 새로운 accessToken 생성 후 응답 헤더에 설정
String newAccessToken = jwtProvider.createAccessToken(userId);
response.setHeader("Authorization", "Bearer " + newAccessToken);
setAccessTokenHeader(response, newAccessToken);
}

// 사용자 인증 (이메일 + 비밀번호 검증)
/**
* 이메일 + 전화번호로 사용자 인증
*/
private User validateUserCredentials(UserRequest.LoginRequest loginRequest) {
User user =
userRepository
Expand All @@ -143,7 +146,9 @@ private User validateUserCredentials(UserRequest.LoginRequest loginRequest) {
return user;
}

// 토큰 발급 및 응답 세팅
/**
* 액세스 / 리프레시 토큰 발급 후 응답 헤더·쿠키에 세팅
*/
private UserResponse issueTokensAndSetResponse(User user, HttpServletResponse response) {
String accessToken = jwtProvider.createAccessToken(user.getId());
String refreshToken = jwtProvider.createRefreshToken(user.getId());
Expand All @@ -161,19 +166,28 @@ private void setAccessTokenHeader(HttpServletResponse response, String accessTok
response.setHeader("Authorization", "Bearer " + accessToken);
}

/**
* refreshToken 쿠키 설정
*
* <p>로컬 개발 환경(secure=false)과 배포 환경(secure=true)을 분리해서 설정한다.
*/
private void setRefreshTokenCookie(
HttpServletResponse response, String refreshToken, long maxAgeSec) {

ResponseCookie.ResponseCookieBuilder cookie =
ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.path("/")
.maxAge(Duration.ofSeconds(maxAgeSec));

if (secure) {
cookie.secure(true).sameSite("None").domain(".danchu.site"); // cross-site 방지 (배포용 HTTPS 설정)
// 배포 환경: HTTPS + SameSite=None 옵션만 사용 (도메인은 기본값 사용)
cookie.secure(true).sameSite("None");
} else {
cookie.secure(false).sameSite("Lax"); // localhost
// 로컬 환경
cookie.secure(false).sameSite("Lax");
}

response.addHeader(HttpHeaders.SET_COOKIE, cookie.build().toString());
}

Expand All @@ -198,28 +212,36 @@ private String resolveAccessToken(HttpServletRequest request) {
return null;
}

/**
* refreshToken 쿠키 제거 (즉시 만료)
*/
private void deleteRefreshTokenCookie(HttpServletResponse response) {
ResponseCookie.ResponseCookieBuilder cookie =
ResponseCookie.from("refreshToken", "").httpOnly(true).path("/").maxAge(Duration.ZERO);
ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.path("/")
.maxAge(Duration.ZERO);

if (secure) {
cookie.secure(true).sameSite("None").domain(".danchu.site");
cookie.secure(true).sameSite("None");
} else {
cookie.secure(false).sameSite("Lax");
}

response.addHeader(HttpHeaders.SET_COOKIE, cookie.build().toString());
}

/**
* 현재 세션(토큰)을 무효화합니다. - AccessToken: 블랙리스트 등록 - RefreshToken: Redis 삭제 - 쿠키: refreshToken 즉시 만료
* 현재 세션(토큰)을 무효화
*
* <p>logout()을 호출하되 예외가 나도 흡수해서 탈퇴 트랜잭션에 영향 주지 않음.
* <p>내부적으로 logout()을 호출하되, 예외가 발생해도 삼켜서 다른 트랜잭션에 영향을 주지 않는다.
*/
public void invalidateCurrentSessionQuietly(
HttpServletRequest request, HttpServletResponse response) {
try {
logout(request, response);
} catch (CustomException ignore) {
// 토큰 검증 실패 등 예외가 나더라도 최소한 쿠키는 정리
deleteRefreshTokenCookie(response);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.umc.springboot.domain.mission.controller;

import com.umc.springboot.domain.mission.dto.request.MissionCreateRequest;
import com.umc.springboot.domain.mission.dto.request.UserMissionUpdateRequest;
import com.umc.springboot.domain.mission.dto.response.MissionResponse;
import com.umc.springboot.domain.mission.dto.response.UserMissionResponse;
import com.umc.springboot.domain.mission.service.MissionService;
Expand All @@ -9,9 +10,12 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand Down Expand Up @@ -40,7 +44,7 @@ public ResponseEntity<BaseResponse<MissionResponse>> createMissionForStore(
@Valid @RequestBody MissionCreateRequest request
) {
Long userId = SecurityUtil.getCurrentUserId();

MissionResponse response = missionService.createMissionForStore(storeId, request);
return ResponseEntity.ok(BaseResponse.success("미션 생성에 성공했습니다.", response));
}
Expand All @@ -57,4 +61,48 @@ public ResponseEntity<BaseResponse<UserMissionResponse>> challengeMission(
UserMissionResponse response = missionService.challengeMission(userId, missionId);
return ResponseEntity.ok(BaseResponse.success("미션 도전에 성공했습니다.", response));
}

/**
* 특정 가게의 미션 목록 조회
*/
@Operation(summary = "특정 가게의 미션 목록 조회")
@GetMapping(value = "/stores/{storeId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<BaseResponse<List<MissionResponse>>> getMissionsByStore(
@PathVariable Long storeId
) {
List<MissionResponse> responses = missionService.getMissionsByStore(storeId);
return ResponseEntity.ok(BaseResponse.success("가게 미션 목록 조회에 성공했습니다.", responses));
}

/**
* 내가 진행중인 미션 목록 조회
*/
@Operation(summary = "내가 진행중인 미션 목록 조회")
@GetMapping(value = "/me/in-progress", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<BaseResponse<List<UserMissionResponse>>> getMyInProgressMissions() {
Long userId = SecurityUtil.getCurrentUserId();
List<UserMissionResponse> responses = missionService.getInProgressMissionsByUser(userId);
return ResponseEntity.ok(BaseResponse.success("진행중인 미션 목록 조회에 성공했습니다.", responses));
}

/**
* 진행 중인 미션 완료 처리
*/
@PatchMapping(
value = "/user-missions/{userMissionId}",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
@Operation(summary = "유저 미션 상태 변경")
public ResponseEntity<BaseResponse<UserMissionResponse>> updateUserMissionStatus(
@PathVariable Long userMissionId,
@RequestBody UserMissionUpdateRequest request
) {
Long userId = SecurityUtil.getCurrentUserId();
UserMissionResponse response =
missionService.updateUserMissionStatus(userId, userMissionId, request.getIsCompleted());

return ResponseEntity.ok(BaseResponse.success("유저 미션 상태 변경에 성공했습니다.", response));
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.umc.springboot.domain.mission.dto.request;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserMissionUpdateRequest {

private Boolean isCompleted;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ public class UserMission extends BaseTimeEntity {

@Column(name = "is_completed", nullable = false)
private Boolean isCompleted;

public void updateCompletion(boolean completed) {
this.isCompleted = completed;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package com.umc.springboot.domain.mission.repository;

import com.umc.springboot.domain.mission.entity.UserMission;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserMissionRepository extends JpaRepository<UserMission, Long> {

boolean existsByUserIdAndMissionId(Long userId, Long missionId);

// 내가 진행중인 미션 목록 (완료되지 않은 것만)
List<UserMission> findByUserIdAndIsCompletedFalse(Long userId);

// 특정 유저의 특정 UserMission 조회 (본인 것만 완료할 수 있게)
Optional<UserMission> findByIdAndUserId(Long id, Long userId);
}
Loading