diff --git a/src/main/java/the_monitor/application/dto/request/AccountLoginRequest.java b/src/main/java/the_monitor/application/dto/request/AccountLoginRequest.java new file mode 100644 index 0000000..03fa1d8 --- /dev/null +++ b/src/main/java/the_monitor/application/dto/request/AccountLoginRequest.java @@ -0,0 +1,18 @@ +package the_monitor.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class AccountLoginRequest { + + @NotBlank(message = "email은 필수입니다.") + private String email; + + @NotBlank(message = "password는 필수입니다.") + private String password; + +} diff --git a/src/main/java/the_monitor/application/service/AccountService.java b/src/main/java/the_monitor/application/service/AccountService.java index 7680a64..059737a 100644 --- a/src/main/java/the_monitor/application/service/AccountService.java +++ b/src/main/java/the_monitor/application/service/AccountService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletResponse; import the_monitor.application.dto.request.AccountCreateRequest; +import the_monitor.application.dto.request.AccountLoginRequest; import the_monitor.domain.model.Account; import java.io.IOException; @@ -15,4 +16,6 @@ public interface AccountService { void verifyEmail(String certifiedKey, HttpServletResponse response) throws IOException; + String accountLogin(AccountLoginRequest request, HttpServletResponse response); + } diff --git a/src/main/java/the_monitor/application/serviceImpl/AccountServiceImpl.java b/src/main/java/the_monitor/application/serviceImpl/AccountServiceImpl.java index c135bd5..19067b3 100644 --- a/src/main/java/the_monitor/application/serviceImpl/AccountServiceImpl.java +++ b/src/main/java/the_monitor/application/serviceImpl/AccountServiceImpl.java @@ -1,17 +1,20 @@ package the_monitor.application.serviceImpl; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import the_monitor.application.dto.request.AccountCreateRequest; +import the_monitor.application.dto.request.AccountLoginRequest; import the_monitor.application.service.AccountService; import the_monitor.application.service.EmailService; import the_monitor.common.ApiException; import the_monitor.common.ErrorStatus; import the_monitor.domain.model.Account; import the_monitor.domain.repository.AccountRepository; +import the_monitor.infrastructure.jwt.JwtProvider; import java.io.IOException; import java.util.UUID; @@ -24,6 +27,7 @@ public class AccountServiceImpl implements AccountService { private final AccountRepository accountRepository; private final EmailService emailService; + private final JwtProvider jwtProvider; @Override public Account findAccountById(Long id) { @@ -74,6 +78,25 @@ public void verifyEmail(String certifiedKey, HttpServletResponse response) throw } + @Override + public String accountLogin(AccountLoginRequest request, HttpServletResponse response) { + + Account account = accountRepository.findByEmail(request.getEmail()); + + if (account == null) throw new ApiException(ErrorStatus._ACCOUNT_NOT_FOUND); + + if (!account.getPassword().equals(request.getPassword())) throw new ApiException(ErrorStatus._WRONG_PASSWORD); + + if (account.isEmailVerified()) { + + jwtProvider.setAddCookieToken(account, response); + + return "로그인 성공"; + } + + return "이메일 인증 필요"; + } + private String generateCertifiedKey() { return UUID.randomUUID().toString(); // UUID 생성 } diff --git a/src/main/java/the_monitor/common/ErrorStatus.java b/src/main/java/the_monitor/common/ErrorStatus.java index f5c2e7c..2cfd8f7 100644 --- a/src/main/java/the_monitor/common/ErrorStatus.java +++ b/src/main/java/the_monitor/common/ErrorStatus.java @@ -12,6 +12,7 @@ public enum ErrorStatus implements BaseErrorCode { // 일반 응답 _ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT404", "해당 계정을 찾을 수 없습니다."), + _WRONG_PASSWORD(HttpStatus.BAD_REQUEST, "ACCOUNT400", "비밀번호가 일치하지 않습니다."), _INVALID_CERTIFIED_KEY(HttpStatus.BAD_REQUEST, "ACCOUNT400", "유효하지 않은 인증키입니다."), _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), diff --git a/src/main/java/the_monitor/domain/repository/AccountRepository.java b/src/main/java/the_monitor/domain/repository/AccountRepository.java index aa3761c..bcc0102 100644 --- a/src/main/java/the_monitor/domain/repository/AccountRepository.java +++ b/src/main/java/the_monitor/domain/repository/AccountRepository.java @@ -7,4 +7,6 @@ public interface AccountRepository extends JpaAccountRepository { Account findByEmailCertificationKey(String certifiedKey); + Account findByEmail(String email); + } diff --git a/src/main/java/the_monitor/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/the_monitor/infrastructure/jwt/JwtAuthenticationFilter.java index 61c8c99..75a8fe0 100644 --- a/src/main/java/the_monitor/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/the_monitor/infrastructure/jwt/JwtAuthenticationFilter.java @@ -28,12 +28,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (isPublicUrl(request.getRequestURI())) { filterChain.doFilter(request, response); return; } String accessToken = resolveTokenFromCookie(request); // 쿠키에서 JWT 토큰 추출 + if (accessToken == null) { request.setAttribute("exception", ErrorStatus._JWT_NOT_FOUND); filterChain.doFilter(request, response); @@ -67,6 +69,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.info("===================== EXPIRED ACCESS-TOKEN"); request.setAttribute("exception", ErrorStatus._JWT_EXPIRED); break; + } filterChain.doFilter(request, response); @@ -91,6 +94,7 @@ private boolean isPublicUrl(String requestUrl) { requestUrl.equals("/api/v1/accounts") || requestUrl.equals("/api/v1/accounts/createAccount") || requestUrl.equals("/api/v1/accounts/verify") || + requestUrl.equals("/api/v1/accounts/login") || requestUrl.startsWith("/api/kindergartens/**") || requestUrl.startsWith("/swagger-ui/**") || requestUrl.startsWith("/swagger-resources/**") || diff --git a/src/main/java/the_monitor/infrastructure/jwt/JwtExceptionFilter.java b/src/main/java/the_monitor/infrastructure/jwt/JwtExceptionFilter.java index 804e1b8..f6ab58f 100644 --- a/src/main/java/the_monitor/infrastructure/jwt/JwtExceptionFilter.java +++ b/src/main/java/the_monitor/infrastructure/jwt/JwtExceptionFilter.java @@ -21,6 +21,7 @@ public class JwtExceptionFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { filterChain.doFilter(request, response); } catch (ApiException e) { @@ -33,4 +34,5 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } + } diff --git a/src/main/java/the_monitor/infrastructure/jwt/JwtProvider.java b/src/main/java/the_monitor/infrastructure/jwt/JwtProvider.java index d3ee826..f27df47 100644 --- a/src/main/java/the_monitor/infrastructure/jwt/JwtProvider.java +++ b/src/main/java/the_monitor/infrastructure/jwt/JwtProvider.java @@ -12,6 +12,9 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Date; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -33,15 +36,47 @@ public class JwtProvider { public JwtProvider(@Value("${jwt.secret_key}") String secretKey, @Value("${jwt.access_token_expire}") Long accessTokenExpire, @Value("${jwt.refresh_token_expire}") Long refreshTokenExpire) { + this.key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // 안전한 256비트 비밀 키 생성 this.ACCESS_TOKEN_EXPIRE_TIME = accessTokenExpire; this.REFRESH_TOKEN_EXPIRE_TIME = refreshTokenExpire; + + } + + public void setAddCookieToken(Account account, HttpServletResponse response) { + + // AccessToken과 RefreshToken 생성 + String accessToken = generateAccessToken(account); + String refreshToken = generateRefreshToken(account); + + int accessTokenExpireTime = Math.toIntExact(ACCESS_TOKEN_EXPIRE_TIME / 1000); + int refreshTokenExpireTime = Math.toIntExact(REFRESH_TOKEN_EXPIRE_TIME / 1000); + + // 쿠키에 AccessToken 저장 + Cookie accessTokenCookie = new Cookie("accessToken", accessToken); + accessTokenCookie.setHttpOnly(true); // HttpOnly 설정 + accessTokenCookie.setSecure(true); // HTTPS에서만 전송 + accessTokenCookie.setPath("/"); // 모든 경로에서 쿠키 접근 가능 + accessTokenCookie.setMaxAge(accessTokenExpireTime); // 만료 시간 설정 + + // 쿠키에 RefreshToken 저장 + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(refreshTokenExpireTime); + + // 응답에 쿠키 추가 + response.addCookie(accessTokenCookie); + response.addCookie(refreshTokenCookie); + } /** * JWT 토큰 생성 (회원가입 또는 로그인 후 호출) */ public String generateAccessToken(Account account) { + Date expiredAt = new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME); return Jwts.builder() @@ -51,9 +86,11 @@ public String generateAccessToken(Account account) { .setExpiration(expiredAt) .signWith(key, SignatureAlgorithm.HS256) .compact(); + } public String generateRefreshToken(Account account) { + Date expiredAt = new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME); return Jwts.builder() .claim("account_id", account.getId()) // User의 ID를 포함 @@ -62,6 +99,7 @@ public String generateRefreshToken(Account account) { .setExpiration(expiredAt) .signWith(key, SignatureAlgorithm.HS256) .compact(); + } /** @@ -75,6 +113,7 @@ public Long getAccountId(String token) { * JWT 토큰에서 Claims(토큰 정보)를 파싱 */ public Claims parseClaims(String token) { + try { return Jwts.parserBuilder() .setSigningKey(key) @@ -84,12 +123,14 @@ public Claims parseClaims(String token) { } catch (ExpiredJwtException e) { throw new ApiException(ErrorStatus._JWT_EXPIRED); } + } /** * JWT 토큰 유효성 검증 */ public String validateToken(String token) { + try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return "VALID"; @@ -98,12 +139,14 @@ public String validateToken(String token) { } catch (SignatureException | MalformedJwtException e) { return "INVALID"; } + } /** * JWT 토큰을 기반으로 Authentication 객체 생성 (User 정보 포함) */ public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); // JWT에서 클레임을 가져옵니다. @@ -113,24 +156,29 @@ public Authentication getAuthentication(String token) { // 사용자 정보 기반으로 Authentication 객체 생성 UserDetails userDetails = new org.springframework.security.core.userdetails.User(email, "", new ArrayList<>()); return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities()); + } /** * SecurityContext에 인증 객체 설정 */ private void setContextHolder(Authentication authentication) { + SecurityContextHolder.getContext().setAuthentication(authentication); + } /** * JWT 토큰의 만료 시간을 가져옵니다. */ public Long getExpiration(String token) { + Date expiration = Jwts.parserBuilder().setSigningKey(key) .build().parseClaimsJws(token).getBody().getExpiration(); long now = new Date().getTime(); return expiration.getTime() - now; + } } \ No newline at end of file diff --git a/src/main/java/the_monitor/infrastructure/security/SecurityConfig.java b/src/main/java/the_monitor/infrastructure/security/SecurityConfig.java index 650dd58..1348108 100644 --- a/src/main/java/the_monitor/infrastructure/security/SecurityConfig.java +++ b/src/main/java/the_monitor/infrastructure/security/SecurityConfig.java @@ -39,22 +39,27 @@ public static BCryptPasswordEncoder bCryptPasswordEncoder() { } private static final String[] WHITE_LIST_URL = { + // Application URLs "/api/v1/**", "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui/**", "/favicon.ico" + }; @Bean public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring() .requestMatchers(WHITE_LIST_URL); + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) @@ -73,10 +78,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); return http.build(); + } @Bean public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); // 모든 메서드 허용 @@ -93,5 +100,7 @@ public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; + } + } \ No newline at end of file diff --git a/src/main/java/the_monitor/presentation/AccountController.java b/src/main/java/the_monitor/presentation/AccountController.java index 0e5ae61..09a0cc3 100644 --- a/src/main/java/the_monitor/presentation/AccountController.java +++ b/src/main/java/the_monitor/presentation/AccountController.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import the_monitor.application.dto.request.AccountCreateRequest; +import the_monitor.application.dto.request.AccountLoginRequest; import the_monitor.application.service.AccountService; import the_monitor.common.ApiResponse; @@ -30,14 +31,12 @@ public void verifyEmail(@RequestParam("certifiedKey") String certifiedKey, HttpS accountService.verifyEmail(certifiedKey, response); } -// -// @GetMapping("/sendVerificationEmail") -// public ApiResponse sendVerificationEmail(@RequestParam("email") String email) { -// -// return ApiResponse.onSuccess(accountService.sendVerificationEmail(email)); -// -// } + @GetMapping("/login") + public ApiResponse Login(@RequestBody @Valid AccountLoginRequest request, HttpServletResponse response) { + return ApiResponse.onSuccess(accountService.accountLogin(request, response)); + + } }