Skip to content

Commit c7213be

Browse files
committed
feat(auth) : 컨트롤러, 서비스 작성 securityContext 사용
1 parent 13fad96 commit c7213be

File tree

19 files changed

+813
-78
lines changed

19 files changed

+813
-78
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ out/
4141

4242
###custom###
4343
application-secret.yml
44+
.env
4445
db_dev.mv.db
4546
db_dev.trace.db

build.gradle

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ dependencies {
4343
runtimeOnly("com.mysql:mysql-connector-j")
4444

4545
// JWT 라이브러리
46-
implementation 'io.jsonwebtoken:jjwt-api:0.12.7'
47-
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7'
48-
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7'
46+
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
47+
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
48+
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
49+
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
50+
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
4951
}
5052

5153
tasks.named('test') {

src/main/java/back/kalender/domain/auth/controller/UserAuthController.java

Lines changed: 268 additions & 58 deletions
Large diffs are not rendered by default.

src/main/java/back/kalender/domain/auth/entity/PasswordResetToken.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
@Table(
1616
name = "password_reset_tokens",
1717
indexes = {
18-
@Index(name = "idx_user_id", columnList = "userId"),
19-
@Index(name = "idx_token", columnList = "token")
18+
@Index(name = "idx_password_user_id", columnList = "userId"),
19+
@Index(name = "idx_password_token", columnList = "token")
2020
}
2121
)
2222
public class PasswordResetToken extends BaseEntity {

src/main/java/back/kalender/domain/auth/entity/RefreshToken.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
@Table(
1616
name = "refresh_tokens",
1717
indexes = {
18-
@Index(name = "idx_user_id", columnList = "userId"),
19-
@Index(name = "idx_token", columnList = "token")
18+
@Index(name = "idx_refresh_user_id", columnList = "userId"),
19+
@Index(name = "idx_refresh_token", columnList = "token")
2020
}
2121
)
2222
public class RefreshToken extends BaseEntity {

src/main/java/back/kalender/domain/auth/service/.gitkeep

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package back.kalender.domain.auth.service;
2+
3+
import back.kalender.domain.auth.dto.request.*;
4+
import back.kalender.domain.auth.dto.response.EmailStatusResponse;
5+
import back.kalender.domain.auth.dto.response.UserLoginResponse;
6+
import back.kalender.domain.auth.dto.response.VerifyEmailResponse;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
9+
public interface AuthService {
10+
UserLoginResponse login(UserLoginRequest request, HttpServletResponse response);
11+
void logout(String refreshToken);
12+
void refreshToken(String refreshToken, HttpServletResponse response);
13+
void sendPasswordResetEmail(UserPasswordResetSendRequest request);
14+
void resetPassword(UserPasswordResetRequest request);
15+
void sendVerifyEmail(VerifyEmailSendRequest request);
16+
VerifyEmailResponse verifyEmail(VerifyEmailRequest request);
17+
EmailStatusResponse getEmailStatus(Long userId);
18+
}
19+
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
package back.kalender.domain.auth.service;
2+
3+
import back.kalender.domain.auth.dto.request.*;
4+
import back.kalender.domain.auth.dto.response.EmailStatusResponse;
5+
import back.kalender.domain.auth.dto.response.UserLoginResponse;
6+
import back.kalender.domain.auth.dto.response.VerifyEmailResponse;
7+
import back.kalender.domain.auth.entity.EmailVerification;
8+
import back.kalender.domain.auth.entity.PasswordResetToken;
9+
import back.kalender.domain.auth.entity.RefreshToken;
10+
import back.kalender.domain.auth.repository.EmailVerificationRepository;
11+
import back.kalender.domain.auth.repository.PasswordResetTokenRepository;
12+
import back.kalender.domain.auth.repository.RefreshTokenRepository;
13+
import back.kalender.domain.user.entity.User;
14+
import back.kalender.domain.user.repository.UserRepository;
15+
import back.kalender.global.exception.ErrorCode;
16+
import back.kalender.global.exception.ServiceException;
17+
import back.kalender.global.security.jwt.JwtProperties;
18+
import back.kalender.global.security.jwt.JwtTokenProvider;
19+
import jakarta.servlet.http.Cookie;
20+
import jakarta.servlet.http.HttpServletResponse;
21+
import lombok.RequiredArgsConstructor;
22+
import org.springframework.security.crypto.password.PasswordEncoder;
23+
import org.springframework.stereotype.Service;
24+
import org.springframework.transaction.annotation.Transactional;
25+
26+
import java.time.LocalDateTime;
27+
import java.util.HashMap;
28+
import java.util.Map;
29+
import java.util.UUID;
30+
31+
@Service
32+
@RequiredArgsConstructor
33+
@Transactional(readOnly = true)
34+
public class AuthServiceImpl implements AuthService {
35+
36+
private final UserRepository userRepository;
37+
private final RefreshTokenRepository refreshTokenRepository;
38+
private final EmailVerificationRepository emailVerificationRepository;
39+
private final PasswordResetTokenRepository passwordResetTokenRepository;
40+
private final JwtTokenProvider jwtTokenProvider;
41+
private final JwtProperties jwtProperties;
42+
private final PasswordEncoder passwordEncoder;
43+
44+
@Override
45+
@Transactional
46+
public UserLoginResponse login(UserLoginRequest request, HttpServletResponse response) {
47+
// 유저 조회
48+
User user = userRepository.findByEmail(request.email())
49+
.orElseThrow(() -> new ServiceException(ErrorCode.INVALID_CREDENTIALS));
50+
51+
// 비밀번호 검증
52+
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
53+
throw new ServiceException(ErrorCode.INVALID_CREDENTIALS);
54+
}
55+
56+
// 토큰 생성
57+
Map<String, Object> claims = new HashMap<>();
58+
claims.put("userId", user.getId());
59+
claims.put("email", user.getEmail());
60+
61+
String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims);
62+
String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims);
63+
64+
// Refresh Token DB 저장
65+
RefreshToken refreshTokenEntity = RefreshToken.create(
66+
user.getId(),
67+
refreshToken,
68+
jwtProperties.getTokenExpiration().getRefresh()
69+
);
70+
refreshTokenRepository.save(refreshTokenEntity);
71+
72+
// Access Token을 Response Header에 설정
73+
response.setHeader("Authorization", "Bearer " + accessToken);
74+
75+
// Refresh Token을 httpOnly secure 쿠키로 설정
76+
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
77+
refreshTokenCookie.setHttpOnly(true);
78+
refreshTokenCookie.setSecure(true);
79+
refreshTokenCookie.setPath("/");
80+
refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60));
81+
response.addCookie(refreshTokenCookie);
82+
83+
return new UserLoginResponse(
84+
user.getId(),
85+
user.getNickname(),
86+
user.getEmail(),
87+
user.getProfileImage(),
88+
user.getEmailVerified() != null ? user.getEmailVerified() : false
89+
);
90+
}
91+
92+
@Override
93+
@Transactional
94+
public void logout(String refreshToken) {
95+
if (refreshToken != null) {
96+
refreshTokenRepository.findByToken(refreshToken)
97+
.ifPresent(refreshTokenRepository::delete);
98+
}
99+
}
100+
101+
@Override
102+
@Transactional
103+
public void refreshToken(String refreshToken, HttpServletResponse response) {
104+
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
105+
throw new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN);
106+
}
107+
108+
// DB에서 Refresh Token 확인
109+
RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(refreshToken)
110+
.orElseThrow(() -> new ServiceException(ErrorCode.INVALID_REFRESH_TOKEN));
111+
112+
// 만료 확인
113+
if (refreshTokenEntity.getExpiredAt().isBefore(LocalDateTime.now())) {
114+
refreshTokenRepository.delete(refreshTokenEntity);
115+
throw new ServiceException(ErrorCode.EXPIRED_REFRESH_TOKEN);
116+
}
117+
118+
// 유저 조회
119+
User user = userRepository.findById(refreshTokenEntity.getUserId())
120+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
121+
122+
// 새 토큰 생성
123+
Map<String, Object> claims = new HashMap<>();
124+
claims.put("userId", user.getId());
125+
claims.put("email", user.getEmail());
126+
127+
String newAccessToken = jwtTokenProvider.createAccessToken(user.getEmail(), claims);
128+
String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getEmail(), claims);
129+
130+
// 기존 Refresh Token 삭제
131+
refreshTokenRepository.delete(refreshTokenEntity);
132+
133+
// 새 Refresh Token 저장
134+
RefreshToken newRefreshTokenEntity = RefreshToken.create(
135+
user.getId(),
136+
newRefreshToken,
137+
jwtProperties.getTokenExpiration().getRefresh()
138+
);
139+
refreshTokenRepository.save(newRefreshTokenEntity);
140+
141+
// Access Token을 Response Header에 설정
142+
response.setHeader("Authorization", "Bearer " + newAccessToken);
143+
144+
// Refresh Token을 httpOnly secure 쿠키로 설정
145+
Cookie refreshTokenCookie = new Cookie("refreshToken", newRefreshToken);
146+
refreshTokenCookie.setHttpOnly(true);
147+
refreshTokenCookie.setSecure(true);
148+
refreshTokenCookie.setPath("/");
149+
refreshTokenCookie.setMaxAge((int) (jwtProperties.getTokenExpiration().getRefresh() * 24 * 60 * 60));
150+
response.addCookie(refreshTokenCookie);
151+
}
152+
153+
@Override
154+
@Transactional
155+
public void sendPasswordResetEmail(UserPasswordResetSendRequest request) {
156+
User user = userRepository.findByEmail(request.email())
157+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
158+
159+
// 기존 토큰 삭제
160+
passwordResetTokenRepository.deleteByUserId(user.getId());
161+
162+
// 새 토큰 생성
163+
String token = UUID.randomUUID().toString();
164+
PasswordResetToken resetToken = PasswordResetToken.create(user.getId(), token);
165+
passwordResetTokenRepository.save(resetToken);
166+
167+
// TODO: 이메일 발송 로직 구현
168+
// emailService.sendPasswordResetEmail(user.getEmail(), token);
169+
}
170+
171+
@Override
172+
@Transactional
173+
public void resetPassword(UserPasswordResetRequest request) {
174+
// 비밀번호 일치 확인
175+
if (!request.newPassword().equals(request.newPasswordConfirm())) {
176+
throw new ServiceException(ErrorCode.PASSWORD_MISMATCH);
177+
}
178+
179+
// 토큰 조회
180+
PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(request.token())
181+
.orElseThrow(() -> new ServiceException(ErrorCode.PASSWORD_RESET_TOKEN_NOT_FOUND));
182+
183+
// 사용 여부 확인
184+
if (resetToken.isUsed()) {
185+
throw new ServiceException(ErrorCode.PASSWORD_RESET_TOKEN_ALREADY_USED);
186+
}
187+
188+
// 만료 확인
189+
if (resetToken.getExpiredAt().isBefore(LocalDateTime.now())) {
190+
throw new ServiceException(ErrorCode.EXPIRED_PASSWORD_RESET_TOKEN);
191+
}
192+
193+
// 유저 조회 및 비밀번호 업데이트
194+
User user = userRepository.findById(resetToken.getUserId())
195+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
196+
197+
user.updatePassword(passwordEncoder.encode(request.newPassword()));
198+
199+
// 토큰 사용 처리
200+
resetToken.markUsed();
201+
}
202+
203+
@Override
204+
@Transactional
205+
public void sendVerifyEmail(VerifyEmailSendRequest request) {
206+
User user = userRepository.findByEmail(request.email())
207+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
208+
209+
// 이미 인증된 경우
210+
if (user.getEmailVerified() != null && user.getEmailVerified()) {
211+
throw new ServiceException(ErrorCode.EMAIL_ALREADY_VERIFIED);
212+
}
213+
214+
// 최근 5분 이내 발송된 인증 코드 확인 (재발송 제한)
215+
emailVerificationRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId())
216+
.ifPresent(verification -> {
217+
if (verification.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1))) {
218+
throw new ServiceException(ErrorCode.EMAIL_VERIFICATION_LIMIT_EXCEEDED);
219+
}
220+
});
221+
222+
// 인증 코드 생성 (6자리 숫자)
223+
String code = String.format("%06d", (int) (Math.random() * 1000000));
224+
225+
// 기존 인증 코드 삭제
226+
emailVerificationRepository.deleteByUserId(user.getId());
227+
228+
// 새 인증 코드 저장
229+
EmailVerification verification = EmailVerification.create(user.getId(), code);
230+
emailVerificationRepository.save(verification);
231+
232+
// TODO: 이메일 발송 로직 구현
233+
// emailService.sendVerificationEmail(user.getEmail(), code);
234+
}
235+
236+
@Override
237+
@Transactional
238+
public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) {
239+
User user = userRepository.findByEmail(request.email())
240+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
241+
242+
// 인증 코드 조회
243+
EmailVerification verification = emailVerificationRepository.findByCode(request.code())
244+
.orElseThrow(() -> new ServiceException(ErrorCode.EMAIL_VERIFICATION_CODE_NOT_FOUND));
245+
246+
// 유저 ID 일치 확인
247+
if (!verification.getUserId().equals(user.getId())) {
248+
throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE);
249+
}
250+
251+
// 사용 여부 확인
252+
if (verification.isUsed()) {
253+
throw new ServiceException(ErrorCode.INVALID_EMAIL_VERIFICATION_CODE);
254+
}
255+
256+
// 만료 확인
257+
if (verification.getExpiredAt().isBefore(LocalDateTime.now())) {
258+
throw new ServiceException(ErrorCode.EXPIRED_EMAIL_VERIFICATION_CODE);
259+
}
260+
261+
// 인증 처리
262+
verification.markUsed();
263+
user.verifyEmail();
264+
265+
return new VerifyEmailResponse(request.email(), true);
266+
}
267+
268+
@Override
269+
public EmailStatusResponse getEmailStatus(Long userId) {
270+
User user = userRepository.findById(userId)
271+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
272+
273+
// 이메일 인증 시간 조회
274+
LocalDateTime verifiedAt = emailVerificationRepository
275+
.findTopByUserIdOrderByCreatedAtDesc(userId)
276+
.filter(EmailVerification::isUsed)
277+
.map(EmailVerification::getUpdatedAt)
278+
.orElse(null);
279+
280+
return new EmailStatusResponse(
281+
user.getId(),
282+
user.getEmail(),
283+
user.getEmailVerified() != null ? user.getEmailVerified() : false,
284+
verifiedAt
285+
);
286+
}
287+
}
288+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package back.kalender.domain.auth.service;
2+
3+
import back.kalender.domain.user.entity.User;
4+
import back.kalender.domain.user.repository.UserRepository;
5+
import back.kalender.global.exception.ErrorCode;
6+
import back.kalender.global.exception.ServiceException;
7+
import back.kalender.global.security.user.CustomUserDetails;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.security.core.GrantedAuthority;
10+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
import org.springframework.security.core.userdetails.UserDetailsService;
13+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
import java.util.Collections;
18+
import java.util.List;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
@Transactional(readOnly = true)
23+
public class CustomUserDetailsService implements UserDetailsService {
24+
25+
private final UserRepository userRepository;
26+
27+
@Override
28+
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
29+
User user = userRepository.findByEmail(email)
30+
.orElseThrow(() -> new ServiceException(ErrorCode.USER_NOT_FOUND));
31+
32+
List<GrantedAuthority> authorities = Collections.singletonList(
33+
new SimpleGrantedAuthority("ROLE_USER")
34+
);
35+
36+
return new CustomUserDetails(
37+
user.getId(),
38+
user.getEmail(),
39+
user.getPassword(),
40+
authorities
41+
);
42+
}
43+
}
44+

0 commit comments

Comments
 (0)