Skip to content

Commit 8dcccdb

Browse files
authored
Feat: 로그인 API 구현 (#64) (#67)
* Feat: 로그인 API 구현 * Test: 로그인 API 테스트 작성 # Conflicts: # src/main/java/com/back/domain/user/entity/User.java * Env: dev 환경 초기 데이터 추가
1 parent e5de47a commit 8dcccdb

File tree

8 files changed

+491
-44
lines changed

8 files changed

+491
-44
lines changed

src/main/java/com/back/domain/user/controller/AuthController.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.back.domain.user.controller;
22

3+
import com.back.domain.user.dto.LoginRequest;
34
import com.back.domain.user.dto.UserRegisterRequest;
45
import com.back.domain.user.dto.UserResponse;
56
import com.back.domain.user.service.UserService;
67
import com.back.global.common.dto.RsData;
78
import io.swagger.v3.oas.annotations.Operation;
89
import io.swagger.v3.oas.annotations.responses.ApiResponse;
910
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import jakarta.servlet.http.HttpServletResponse;
1012
import jakarta.validation.Valid;
1113
import lombok.RequiredArgsConstructor;
1214
import org.springframework.http.HttpStatus;
@@ -44,4 +46,24 @@ public ResponseEntity<RsData<UserResponse>> register(
4446
response
4547
));
4648
}
49+
50+
@PostMapping("/login")
51+
@Operation(summary = "로그인", description = "username + password로 로그인합니다.")
52+
@ApiResponses({
53+
@ApiResponse(responseCode = "200", description = "로그인 성공"),
54+
@ApiResponse(responseCode = "401", description = "잘못된 아이디/비밀번호"),
55+
@ApiResponse(responseCode = "403", description = "이메일 미인증/정지 계정"),
56+
@ApiResponse(responseCode = "410", description = "탈퇴한 계정")
57+
})
58+
public ResponseEntity<RsData<UserResponse>> login(
59+
@Valid @RequestBody LoginRequest request,
60+
HttpServletResponse response
61+
) {
62+
UserResponse loginResponse = userService.login(request, response);
63+
return ResponseEntity
64+
.ok(RsData.success(
65+
"로그인에 성공했습니다.",
66+
loginResponse
67+
));
68+
}
4769
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.domain.user.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
/**
6+
* 사용자 로그인 요청을 나타내는 DTO
7+
*
8+
* @param username 사용자의 로그인 id
9+
* @param password 사용자의 비밀번호
10+
*/
11+
public record LoginRequest(
12+
@NotBlank String username,
13+
@NotBlank String password
14+
) {}

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import lombok.Builder;
1414
import lombok.Getter;
1515
import lombok.NoArgsConstructor;
16+
import lombok.Setter;
1617
import lombok.experimental.SuperBuilder;
1718

1819
import java.util.ArrayList;
@@ -38,6 +39,8 @@ public class User extends BaseEntity {
3839

3940
private String providerId;
4041

42+
// 사용자 상태 변경
43+
@Setter
4144
@Enumerated(EnumType.STRING)
4245
private UserStatus userStatus;
4346

src/main/java/com/back/domain/user/service/UserService.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package com.back.domain.user.service;
22

3+
import com.back.domain.user.dto.LoginRequest;
34
import com.back.domain.user.dto.UserRegisterRequest;
45
import com.back.domain.user.dto.UserResponse;
56
import com.back.domain.user.entity.User;
67
import com.back.domain.user.entity.UserProfile;
8+
import com.back.domain.user.entity.UserStatus;
79
import com.back.domain.user.repository.UserProfileRepository;
810
import com.back.domain.user.repository.UserRepository;
911
import com.back.global.exception.CustomException;
1012
import com.back.global.exception.ErrorCode;
13+
import com.back.global.security.CurrentUser;
14+
import com.back.global.security.JwtTokenProvider;
15+
import jakarta.servlet.http.Cookie;
16+
import jakarta.servlet.http.HttpServletResponse;
1117
import lombok.RequiredArgsConstructor;
1218
import org.springframework.security.crypto.password.PasswordEncoder;
1319
import org.springframework.stereotype.Service;
@@ -20,6 +26,7 @@ public class UserService {
2026
private final UserRepository userRepository;
2127
private final UserProfileRepository userProfileRepository;
2228
private final PasswordEncoder passwordEncoder;
29+
private final JwtTokenProvider jwtTokenProvider;
2330

2431
/**
2532
* 회원가입 서비스
@@ -56,13 +63,65 @@ public UserResponse register(UserRegisterRequest request) {
5663
// 연관관계 설정
5764
user.setUserProfile(profile);
5865

66+
// TODO: 임시 로직 - 이메일 인증 기능 개발 전까지는 바로 ACTIVE 처리
67+
user.setUserStatus(UserStatus.ACTIVE);
68+
5969
// 저장 (cascade로 Profile도 함께 저장됨)
6070
User saved = userRepository.save(user);
6171

72+
// TODO: 이메일 인증 로직 추가 예정
73+
6274
// UserResponse 변환 및 반환
6375
return UserResponse.from(saved, profile);
6476
}
6577

78+
/**
79+
* 로그인 서비스
80+
* 1. 사용자 조회 (username)
81+
* 2. 비밀번호 검증
82+
* 3. 사용자 상태 체크 (PENDING, SUSPENDED, DELETED)
83+
* 4. Access Token, Refresh Token 생성
84+
* 5. Refresh Token을 HttpOnly 쿠키로 설정
85+
* 6. Access Token을 응답 헤더에 설정
86+
* 7. UserResponse 반환
87+
*/
88+
public UserResponse login(LoginRequest request, HttpServletResponse response) {
89+
// 사용자 조회
90+
User user = userRepository.findByUsername(request.username())
91+
.orElseThrow(() -> new CustomException(ErrorCode.INVALID_CREDENTIALS));
92+
93+
// 비밀번호 검증
94+
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
95+
throw new CustomException(ErrorCode.INVALID_CREDENTIALS);
96+
}
97+
98+
// 사용자 상태 검증
99+
switch (user.getUserStatus()) {
100+
case PENDING -> throw new CustomException(ErrorCode.USER_EMAIL_NOT_VERIFIED);
101+
case SUSPENDED -> throw new CustomException(ErrorCode.USER_SUSPENDED);
102+
case DELETED -> throw new CustomException(ErrorCode.USER_DELETED);
103+
}
104+
105+
// 토큰 생성
106+
String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name());
107+
String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
108+
109+
// TODO: Refresh Token 저장소에 저장 로직 추가 예정 (현재는 stateless 방식)
110+
// Refresh Token을 HttpOnly 쿠키로 설정
111+
Cookie cookie = new Cookie("refreshToken", refreshToken);
112+
cookie.setHttpOnly(true);
113+
cookie.setSecure(true);
114+
cookie.setPath("/api/auth/refresh");
115+
cookie.setMaxAge(7 * 24 * 60 * 60); // TODO: 하드 코딩된 만료 시간 상수로 분리
116+
response.addCookie(cookie);
117+
118+
// Access Token을 응답 헤더에 설정
119+
response.setHeader("Authorization", "Bearer " + accessToken);
120+
121+
// UserResponse 반환
122+
return UserResponse.from(user, user.getUserProfile());
123+
}
124+
66125
/**
67126
* 회원가입 시 중복 검증
68127
* - username, email, nickname

src/main/java/com/back/global/exception/ErrorCode.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ public enum ErrorCode {
1414
EMAIL_DUPLICATED(HttpStatus.CONFLICT, "USER_003", "이미 사용 중인 이메일입니다."),
1515
NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_004", "이미 사용 중인 닉네임입니다."),
1616
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_005", "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다."),
17+
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER_006", "아이디 또는 비밀번호가 올바르지 않습니다."),
18+
USER_EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "USER_007", "이메일 인증 후 로그인할 수 있습니다."),
19+
USER_SUSPENDED(HttpStatus.FORBIDDEN, "USER_008", "정지된 계정입니다. 관리자에게 문의하세요."),
20+
USER_DELETED(HttpStatus.GONE, "USER_009", "탈퇴한 계정입니다."),
1721

1822
// ======================== 스터디룸 관련 ========================
1923
ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.back.global.initData;
2+
3+
import com.back.domain.user.entity.User;
4+
import com.back.domain.user.entity.UserProfile;
5+
import com.back.domain.user.entity.UserStatus;
6+
import com.back.domain.user.repository.UserRepository;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.boot.ApplicationRunner;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.context.annotation.Profile;
12+
import org.springframework.security.crypto.password.PasswordEncoder;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
@Configuration
16+
@Profile("dev")
17+
@RequiredArgsConstructor
18+
public class DevInitData {
19+
private final UserRepository userRepository;
20+
private final PasswordEncoder passwordEncoder;
21+
22+
@Bean
23+
ApplicationRunner DevInitDataApplicationRunner() {
24+
return args -> {
25+
initUsers();
26+
};
27+
}
28+
29+
@Transactional
30+
public void initUsers() {
31+
if (userRepository.count() == 0) {
32+
User admin = User.createAdmin(
33+
"admin",
34+
35+
passwordEncoder.encode("12345678!")
36+
);
37+
admin.setUserProfile(new UserProfile(admin, "관리자", null, null, null, 0));
38+
userRepository.save(admin);
39+
40+
User user1 = User.createUser(
41+
"user1",
42+
43+
passwordEncoder.encode("12345678!")
44+
);
45+
user1.setUserProfile(new UserProfile(user1, "사용자1", null, null, null, 0));
46+
user1.setUserStatus(UserStatus.ACTIVE);
47+
userRepository.save(user1);
48+
49+
User user2 = User.createUser(
50+
"user2",
51+
52+
passwordEncoder.encode("12345678!")
53+
);
54+
user2.setUserProfile(new UserProfile(user2, "사용자2", null, null, null, 0));
55+
user2.setUserStatus(UserStatus.ACTIVE);
56+
userRepository.save(user2);
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)