Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fa667a6
feat: USER 엔티티 ENUM 분리
ruudska6 Jan 23, 2026
9895058
feat: USER 레포지토리 구현
ruudska6 Jan 23, 2026
41284a0
feat: 사용자 도메인 서비스 구현
ruudska6 Jan 23, 2026
222a4dd
Merge branch 'develop' of https://github.com/DearObjet/DearObjet-back…
ruudska6 Jan 24, 2026
09b86fe
feat: 스프링 시큐리티 사용자 인증 정보 구성
ruudska6 Jan 24, 2026
306159b
feat: JWT 기반 인증 필터 및 토큰 처리 구현
ruudska6 Jan 24, 2026
3cd7f19
fix: 추가 정보를 이후에 받을 수 있도록 최초 회원가입시 nullable 허용
ruudska6 Jan 24, 2026
6c43e19
feat: 카카오 소셜 로그인 구현
ruudska6 Jan 24, 2026
94ae2c1
feat: 카카오 소셜 로그인 구현
ruudska6 Jan 24, 2026
ad96743
feat: 로그아웃 구현
ruudska6 Jan 24, 2026
a646df8
refactor: UserStatus는 활성/비활성화만 담당하도록 변경과 Role에 TEMP 추가
ruudska6 Jan 25, 2026
6593aec
feat: JWT 토큰 claim에 ROLE 추가
ruudska6 Jan 25, 2026
fb4236a
feat: 추가 회원가입 메서드 구현
ruudska6 Jan 25, 2026
25e46c1
feat: 비로그인 API 요청 시 카카오 로그인 페이지 리다이렉트 막고 예외처리 나도록 변경
ruudska6 Jan 25, 2026
435d976
feat: redis 의존성 추가 및 설정 정보 작성
ruudska6 Feb 3, 2026
f3ef6a1
feat: redis 의존성 추가 및 설정 정보 작성
ruudska6 Feb 3, 2026
96b814c
refactor: 공통 API 리스폰스를 반환할수 있도록 변경
ruudska6 Feb 3, 2026
b6b4a77
feat: redis를 활용한 refresh 토큰 조회 로직 작성
ruudska6 Feb 3, 2026
5de88cb
feat: 리프레쉬 토큰 발급 구현
ruudska6 Feb 3, 2026
292b9ef
feat: 리프레쉬 토큰을 가지고 액세스 토큰 발급을 위한 API 구현
ruudska6 Feb 3, 2026
c60abf7
feat: 액세스토큰을 폐기해 로그아웃을 하던 방식 리프레쉬 토큰 폐기로 변경
ruudska6 Feb 3, 2026
ef8fc18
feat: 리프레쉬 토큰 탈취 당할 시 계속 액세스토큰을 발급받을 수 없도록 로테이션 전략 적용
ruudska6 Feb 3, 2026
e5034b0
chore: 개발 편의성을 위해 시큐리티 거치지 않도록 설정 분리
ruudska6 Feb 3, 2026
5418a80
refactor: 과도한 책임을 가지고 있는 Controller에서 Service 분리
ruudska6 Feb 3, 2026
b28ad9c
feat: 추가 회원가입 분기 처리
ruudska6 Feb 3, 2026
bc5f635
chore: 패키지 이동
ruudska6 Feb 3, 2026
27c5a09
chore: 패키지 이동
ruudska6 Feb 3, 2026
9cc6ca4
conf: Securiy 운영/개발 환경 분리 설정
ruudska6 Feb 3, 2026
b0690ef
fix: 설계 변경으로 인한 users/complete API 로직 변경
ruudska6 Feb 3, 2026
ba26f7d
Merge branch 'develop' into feature/auth
ruudska6 Feb 3, 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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

tasks.named('test') {
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,15 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data

redis:
image: redis:7-alpine
container_name: dearobjet-redis
ports:
- "6379:6379"
command: [ "redis-server", "--appendonly", "yes" ]
volumes:
- redisdata:/data

volumes:
pgdata:
redisdata:
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package app.dearobjet.backend.domain.user.controller;

import app.dearobjet.backend.domain.user.dto.CompleteSignupRequest;
import app.dearobjet.backend.domain.user.service.UserService;
import app.dearobjet.backend.global.api.ApiResponse;
import app.dearobjet.backend.global.auth.security.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;

/**
* 추가 회원가입 (TEMP → CUSTOMER / ARTIST / SHOP)
*/
@PostMapping("/complete")
public ResponseEntity<ApiResponse<Void>> completeSignup(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody CompleteSignupRequest request
) {
Long userId = userDetails.getUserId();

userService.completeSignup(
userId,
request.getName(),
request.getEmail(),
request.getPhoneNumber(),
request.getSmsAgreement(),
request.getMarketingAgreement(),
request.getRole()
);

return ResponseEntity.ok(ApiResponse.of(null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package app.dearobjet.backend.domain.user.dto;

import app.dearobjet.backend.domain.user.enums.Role;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class CompleteSignupRequest {

private String name;
private String email;
private String phoneNumber;
private Boolean smsAgreement;
private Boolean marketingAgreement;
private Role role; // CUSTOMER / ARTIST / SHOP
}
43 changes: 32 additions & 11 deletions src/main/java/app/dearobjet/backend/domain/user/entity/User.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.dearobjet.backend.domain.user.entity;

import app.dearobjet.backend.global.common.entity.BaseTimeEntity;
import app.dearobjet.backend.domain.user.enums.Role;
import app.dearobjet.backend.domain.user.enums.UserStatus;
import jakarta.persistence.*;
import lombok.*;

Expand All @@ -18,13 +19,12 @@ public class User extends BaseTimeEntity {
@Column(name = "user_id")
private Long userId;

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

@Column(nullable = false)
private String name;

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

@Column(name = "profile_image")
Expand All @@ -36,18 +36,20 @@ public class User extends BaseTimeEntity {
@Column(name = "marketing_agreement")
private Boolean marketingAgreement;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private String role; // CUSTOMER, ARTIST, SHOP
private Role role;

@Column(name = "profile_url")
private String profileUrl;

@Column(name = "user_status")
private String userStatus;

@Enumerated(EnumType.STRING)
@Column(name = "user_status", nullable = false)
private UserStatus userStatus;

@Column(name = "login_type")
private String loginType; // EMAIL, KAKAO, NAVER, GOOGLE
// 카카오 단일
// @Column(name = "login_type")
// private String loginType; // EMAIL, KAKAO, NAVER, GOOGLE

@Column(name = "social_id")
private String socialId;
Expand All @@ -59,7 +61,26 @@ public void updateProfile(String name, String profileUrl) {
}

public void deactivate() {
this.userStatus = "INACTIVE";
this.userStatus = UserStatus.INACTIVE;
}

public void completeRegistration(
String name,
String email,
String phoneNumber,
Boolean smsAgreement,
Boolean marketingAgreement,
Role role
) {
if (this.role != Role.TEMP) {
throw new IllegalStateException("User already registered");
}

this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.smsAgreement = smsAgreement;
this.marketingAgreement = marketingAgreement;
this.role = role;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.dearobjet.backend.domain.user.enums;

public enum Role {
TEMP, // OAuth만 완료 (추가회원가입 전)
CUSTOMER,
ARTIST,
SHOP
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package app.dearobjet.backend.domain.user.enums;

public enum UserStatus {
ACTIVE,
INACTIVE
}

Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package app.dearobjet.backend.domain.user.repository;

import app.dearobjet.backend.domain.user.entity.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findBySocialId(String socialId);

Optional<User> findByEmail(String email);

boolean existsByEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package app.dearobjet.backend.domain.user.service;

import app.dearobjet.backend.domain.user.entity.User;
import app.dearobjet.backend.domain.user.enums.Role;

public interface UserService {

// 카카오 OAuth 사용자 조회 또는 신규 생성
User getOrCreateKakaoUser(String socialId);

// 추가 회원가입
void completeSignup(
Long userId,
String name,
String email,
String phoneNumber,
Boolean smsAgreement,
Boolean marketingAgreement,
Role role
);

// 회원 비활성화
void deactivateUser(Long userId);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package app.dearobjet.backend.domain.user.service;

import app.dearobjet.backend.domain.user.entity.User;
import app.dearobjet.backend.domain.user.enums.Role;
import app.dearobjet.backend.domain.user.enums.UserStatus;
import app.dearobjet.backend.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserService {

private final UserRepository userRepository;

/**
* 카카오 OAuth 로그인 사용자 조회 or 생성
*/
@Override
public User getOrCreateKakaoUser(String socialId) {
return userRepository.findBySocialId(socialId)
.orElseGet(() -> createTempUser(socialId));
}

private User createTempUser(String socialId) {
User user = User.builder()
.socialId(socialId)
.role(Role.TEMP)
.userStatus(UserStatus.ACTIVE)
.build();

return userRepository.save(user);
}

/**
* 추가 회원가입 완료
*/
@Override
public void completeSignup(
Long userId,
String name,
String email,
String phoneNumber,
Boolean smsAgreement,
Boolean marketingAgreement,
Role role
) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));

// 이메일 중복 체크
if (userRepository.existsByEmail(email)) {
throw new IllegalStateException("Email already exists");
}

user.completeRegistration(
name,
email,
phoneNumber,
smsAgreement,
marketingAgreement,
role
);
}

/**
* 회원 비활성화
*/
@Override
public void deactivateUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));

user.deactivate();
}
}
10 changes: 10 additions & 0 deletions src/main/java/app/dearobjet/backend/global/api/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.dearobjet.backend.global.api;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ErrorResponse {
private String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package app.dearobjet.backend.global.auth.controller;

import app.dearobjet.backend.global.auth.service.RefreshTokenRedisService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthController {

private final RefreshTokenRedisService refreshTokenRedisService;

@PostMapping("/auth/logout")
public ResponseEntity<Void> logout(HttpServletRequest request,
HttpServletResponse response) {

// 1. refresh 쿠키에서 값 추출
String refreshToken = null;
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("refreshToken".equals(cookie.getName())) {
refreshToken = cookie.getValue();
break;
}
}
}

// 2. Redis에서 refresh 삭제
if (refreshToken != null) {
refreshTokenRedisService.delete(refreshToken);
}

// 3. refresh 쿠키 삭제
Cookie deleteCookie = new Cookie("refreshToken", null);
deleteCookie.setHttpOnly(true);
deleteCookie.setSecure(true); // https 환경
deleteCookie.setPath("/");
deleteCookie.setMaxAge(0);

response.addCookie(deleteCookie);

return ResponseEntity.noContent().build();
}
}
Loading