-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthController.java
More file actions
225 lines (194 loc) · 11.3 KB
/
Copy pathAuthController.java
File metadata and controls
225 lines (194 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
package com.carecode.domain.user.controller;
import com.carecode.core.annotation.LogExecutionTime;
import com.carecode.core.annotation.RateLimit;
import com.carecode.core.controller.BaseController;
import com.carecode.core.security.CurrentUserFacade;
import com.carecode.core.security.RefreshTokenCookieFactory;
import com.carecode.core.exception.UserNotFoundException;
import com.carecode.domain.user.dto.request.LoginRequestDto;
import com.carecode.domain.user.dto.request.RefreshTokenRequest;
import com.carecode.domain.user.dto.request.SignUpRequest;
import com.carecode.domain.user.dto.response.TokenDto;
import com.carecode.domain.user.dto.response.UserDto;
import com.carecode.domain.user.entity.User;
import com.carecode.domain.user.service.AuthService;
import com.carecode.domain.user.service.JwtService;
import com.carecode.domain.user.service.EmailVerificationService;
import com.carecode.domain.user.service.UserService;
import com.carecode.domain.user.service.refreshtoken.RefreshTokenStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import com.carecode.core.handler.ApiSuccess;
/** 통합 인증 컨트롤러 */
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "인증", description = "통합 인증 API (일반 로그인, 토큰 갱신)")
public class AuthController extends BaseController {
private final UserService userService;
private final JwtService jwtService;
private final AuthService authService;
private final EmailVerificationService emailVerificationService;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenStore refreshTokenStore;
private final CurrentUserFacade currentUserFacade;
private final RefreshTokenCookieFactory refreshTokenCookieFactory;
// ====================
// 일반 로그인 ====================
// 일반 로그인
@PostMapping("/login")
@LogExecutionTime(warnThreshold = 1000)
@RateLimit(requests = 5, windowSeconds = 60, message = "로그인 시도 횟수를 초과했습니다. 1분 후 다시 시도해주세요.")
@Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인")
public ResponseEntity<TokenDto> login(@Parameter(description = "로그인 정보", required = true) @Valid @RequestBody LoginRequestDto request) {
// 가입 여부가 응답으로 새어나가지 않도록, 실패 사유와 무관하게 동일한 401 을 반환한다. (미가입 404 / 비밀번호 불일치 401 로 나뉘면 이메일 열거가 가능하다.)
User userEntity = userService.findActiveUserEntityByEmail(request.getEmail()).orElse(null);
if (userEntity == null
|| userEntity.getPassword() == null
|| !passwordEncoder.matches(request.getPassword(), userEntity.getPassword())
|| !Boolean.TRUE.equals(userEntity.getIsActive())) {
log.warn("로그인 실패 - 이메일={}", request.getEmail());
return ResponseEntity.status(401).body(TokenDto.builder()
.success(false)
.message("이메일 또는 비밀번호가 올바르지 않습니다.")
.build());
}
// 마지막 로그인 시간 업데이트
userEntity.setLastLoginAt(LocalDateTime.now());
userEntity.setUpdatedAt(LocalDateTime.now());
userService.saveUser(userEntity);
return withRefreshCookie(authService.issueTokenForUser(userEntity, "로그인 성공!"));
}
// 회원가입
@PostMapping("/register")
@LogExecutionTime
@RateLimit(requests = 5, windowSeconds = 3600, message = "가입 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.")
@Operation(summary = "회원가입", description = "새로운 사용자 등록")
public ResponseEntity<TokenDto> register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody SignUpRequest request) {
UserDto createdUser = userService.createUser(request);
User user = userService.getUserEntityByEmail(createdUser.getEmail());
TokenDto tokenDto = authService.issueTokenForUser(user, "회원가입 성공!");
tokenDto.setUser(createdUser);
return withRefreshCookie(tokenDto);
}
/** 발급된 리프레시 토큰을 HttpOnly 쿠키로도 내려보낸다. */
private ResponseEntity<TokenDto> withRefreshCookie(TokenDto tokenDto) {
if (tokenDto.getRefreshToken() == null) {
return ResponseEntity.ok(tokenDto);
}
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.create(tokenDto.getRefreshToken()).toString())
.body(tokenDto);
}
// ====================
// 토큰 관리 ====================
// 토큰 갱신
@PostMapping("/refresh")
@LogExecutionTime
@Operation(summary = "토큰 갱신",
description = "Refresh Token 으로 새로운 Access Token 을 발급"
+ "토큰은 HttpOnly 쿠키에서 우선 읽고, 없으면 요청 본문에서 읽습니다.")
public ResponseEntity<TokenDto> refreshToken(
@Parameter(description = "토큰 갱신 정보 (쿠키를 쓰는 경우 생략 가능)")
@RequestBody(required = false) RefreshTokenRequest request,
HttpServletRequest httpRequest) {
// 쿠키를 우선한다. 본문은 쿠키를 쓸 수 없는 클라이언트를 위한 대체 경로다.
String refreshToken = refreshTokenCookieFactory.read(httpRequest)
.orElseGet(() -> request != null ? request.getRefreshToken() : null);
// validateToken 이 아니라 validateRefreshToken 이어야 한다.
// 전자는 서명·만료·issuer 만 보므로 Access Token 도 통과한다. 서버 세션 저장소를 쓰지 않는
// 기본 설정(jwt.refresh-token.store=none)에서는 뒤의 isRegistered 도 항상 true 라,
// 탈취한 1시간짜리 Access Token 을 30일짜리 Refresh Token 으로 바꿀 수 있었다.
if (refreshToken == null || refreshToken.isBlank() || !jwtService.validateRefreshToken(refreshToken)) {
return unauthorizedRefresh("유효하지 않은 Refresh Token입니다.");
}
// Refresh Token에서 사용자 정보 추출
String userId = jwtService.getUserIdFromToken(refreshToken);
String email = jwtService.getEmailFromToken(refreshToken);
if (!refreshTokenStore.isRegistered(refreshToken, userId)) {
return unauthorizedRefresh("세션이 만료되었거나 로그아웃된 Refresh Token입니다.");
}
// 사용자 존재 확인
UserDto user = userService.getUserById(userId);
// 새로운 Access Token 생성
String newAccessToken = jwtService.generateAccessToken(userId, email, user.getRole(), user.getName());
// 새로운 Refresh Token 생성 (토큰 로테이션)
String newRefreshToken = jwtService.generateRefreshToken(userId, email);
refreshTokenStore.remove(refreshToken, userId);
refreshTokenStore.register(userId, newRefreshToken);
TokenDto tokenDto = TokenDto.builder()
.success(true)
.message("토큰 갱신 성공!")
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn(jwtService.getAccessTokenExpirationMs())
.refreshExpiresIn(jwtService.getRefreshTokenExpirationMs())
.user(user)
.build();
return withRefreshCookie(tokenDto);
}
/** 갱신 실패 시에는 남아 있는 쿠키도 함께 지워 재시도 루프를 끊는다. */
private ResponseEntity<TokenDto> unauthorizedRefresh(String message) {
return ResponseEntity.status(401)
.header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.expire().toString())
.body(TokenDto.builder()
.success(false)
.message(message)
.build());
}
@PostMapping("/logout")
@LogExecutionTime
@Operation(summary = "로그아웃", description = "서버에 등록된 리프레시 토큰 세션을 모두 폐기합니다")
public ResponseEntity<ApiSuccess> logout() {
refreshTokenStore.removeAllForUser(currentUserFacade.requireCurrentUserId());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.expire().toString())
.body(ApiSuccess.of("로그아웃되었습니다."));
}
// ====================
// 이메일 인증 ====================
// 이메일 인증 토큰 검증
@GetMapping("/verify")
@LogExecutionTime
@Operation(summary = "이메일 인증", description = "이메일 인증 토큰을 검증")
public ResponseEntity<ApiSuccess> verifyEmail(@RequestParam String token) {
boolean ok = emailVerificationService.verifyEmail(token);
String message = ok ? "이메일 인증이 완료되었습니다." : "유효하지 않거나 만료된 토큰입니다.";
return ResponseEntity.ok(ApiSuccess.of(message));
}
// 인증 코드 발송
// 메일 발송은 비용이 들고 수신자에게는 스팸이 된다. 서비스 계층의 주소별 쿨다운(60초)과 별개로,
// 주소만 바꿔가며 대량 발송하는 것을 막기 위해 호출자 단위 상한을 둔다.
@PostMapping("/send-code")
@LogExecutionTime
@RateLimit(requests = 5, windowSeconds = 600, message = "인증 코드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.")
@Operation(summary = "이메일 인증 코드 발송")
public ResponseEntity<ApiSuccess> sendVerificationCode(@RequestParam String email) {
emailVerificationService.sendVerificationCode(email);
return ResponseEntity.ok(ApiSuccess.of("인증 코드가 발송되었습니다."));
}
// 인증 코드 검증
// 6자리 숫자 코드라 후보가 90만 개뿐이다. 코드당 시도 제한(EmailVerificationService)에 더해
// 호출 자체를 제한해 다른 주소로 갈아타며 시도하는 것도 막는다.
@PostMapping("/verify-code")
@LogExecutionTime
@RateLimit(requests = 10, windowSeconds = 600, message = "인증 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.")
@Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증")
public ResponseEntity<ApiSuccess> verifyCode(@RequestParam String email, @RequestParam String code) {
boolean ok = emailVerificationService.verifyCode(email, code);
String message = ok ? "인증 코드가 확인되었습니다." : "유효하지 않거나 만료된 코드입니다.";
return ResponseEntity.ok(ApiSuccess.of(message));
}
}