diff --git a/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java b/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java index f04d851..14bbcdf 100644 --- a/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java +++ b/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.mycom.socket.auth.config; import com.mycom.socket.auth.jwt.JWTFilter; +import com.mycom.socket.auth.jwt.JWTProperties; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.auth.service.MemberDetailsService; import lombok.RequiredArgsConstructor; @@ -20,6 +21,7 @@ public class SecurityConfig{ private final JWTUtil jwtUtil; + private final JWTProperties properties; private final MemberDetailsService memberDetailsService; @Bean @@ -30,7 +32,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) - .addFilterBefore(new JWTFilter(jwtUtil, memberDetailsService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore( + new JWTFilter(properties, jwtUtil, memberDetailsService), + UsernamePasswordAuthenticationFilter.class + ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index a733f77..b9c624c 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -1,16 +1,14 @@ package com.mycom.socket.auth.controller; -import com.mycom.socket.auth.dto.request.EmailRequestDto; -import com.mycom.socket.auth.dto.request.EmailVerificationRequestDto; -import com.mycom.socket.auth.dto.request.LoginRequestDto; -import com.mycom.socket.auth.dto.request.RegisterRequestDto; -import com.mycom.socket.auth.dto.response.EmailVerificationCheckResponseDto; -import com.mycom.socket.auth.dto.response.EmailVerificationResponseDto; -import com.mycom.socket.auth.dto.response.LoginResponseDto; +import com.mycom.socket.auth.dto.request.EmailRequest; +import com.mycom.socket.auth.dto.request.EmailVerificationRequest; +import com.mycom.socket.auth.dto.request.LoginRequest; +import com.mycom.socket.auth.dto.request.RegisterRequest; +import com.mycom.socket.auth.dto.response.EmailVerificationResponse; +import com.mycom.socket.auth.dto.response.LoginResponse; +import com.mycom.socket.auth.dto.response.RegisterResponse; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.auth.service.MailService; -import com.mycom.socket.auth.service.RateLimiter; -import com.mycom.socket.global.exception.BaseException; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,44 +21,31 @@ public class AuthController { private final AuthService authService; private final MailService mailService; - private final RateLimiter rateLimiter; @PostMapping("/login") - public LoginResponseDto login(@Valid @RequestBody LoginRequestDto request, - HttpServletResponse response) { + public LoginResponse login(@Valid @RequestBody LoginRequest request, + HttpServletResponse response) { return authService.login(request, response); } + @PostMapping("/register") + public RegisterResponse register(@Valid @RequestBody RegisterRequest request) { + return authService.register(request); + } + @PostMapping("/logout") public void logout(HttpServletResponse response) { authService.logout(response); } - @PostMapping("/register") - public Long register(@Valid @RequestBody RegisterRequestDto request) { - return authService.register(request); - } - @PostMapping("/verification") - public EmailVerificationResponseDto mailSend(@Valid @RequestBody EmailRequestDto emailRequestDto) { - try { - boolean isSuccess = mailService.sendMail(emailRequestDto.email()); - return isSuccess ? EmailVerificationResponseDto.createSuccessResponse() : EmailVerificationResponseDto.createFailureResponse("이메일 전송에 실패했습니다."); - } catch (BaseException e) { - return EmailVerificationResponseDto.createFailureResponse(e.getMessage()); - } + public EmailVerificationResponse sendVerificationEmail(@Valid @RequestBody EmailRequest request) { + return mailService.sendMail(request.email()); } @PostMapping("/email/verify") - public EmailVerificationCheckResponseDto mailCheck(@Valid @RequestBody EmailVerificationRequestDto emailRequestDto) { - try{ - rateLimiter.checkRateLimit(emailRequestDto.email());// 시도 횟수 제한 - boolean isVerified = mailService.verifyCode(emailRequestDto.email(), emailRequestDto.code()); - return isVerified ? EmailVerificationCheckResponseDto.createSuccessResponse() : - EmailVerificationCheckResponseDto.createFailureResponse("이메일 인증에 실패했습니다."); - }catch (BaseException e){ - return EmailVerificationCheckResponseDto.createFailureResponse(e.getMessage()); - } + public EmailVerificationResponse verifyEmail(@Valid @RequestBody EmailVerificationRequest request) { + return mailService.verifyCode(request.email(), request.code()); } } diff --git a/src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/EmailRequest.java similarity index 90% rename from src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java rename to src/main/java/com/mycom/socket/auth/dto/request/EmailRequest.java index abf4411..1754adf 100644 --- a/src/main/java/com/mycom/socket/auth/dto/request/EmailRequestDto.java +++ b/src/main/java/com/mycom/socket/auth/dto/request/EmailRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotEmpty; -public record EmailRequestDto( +public record EmailRequest( @NotEmpty(message = "이메일 주소를 입력해주세요.") @Email(message = "유효하지 않은 이메일 형식입니다.") String email diff --git a/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequest.java similarity index 92% rename from src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java rename to src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequest.java index fbb3e32..674f234 100644 --- a/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequestDto.java +++ b/src/main/java/com/mycom/socket/auth/dto/request/EmailVerificationRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; -public record EmailVerificationRequestDto( +public record EmailVerificationRequest( @NotEmpty(message = "이메일 주소를 입력해주세요.") @Email(message = "유효하지 않은 이메일 형식입니다.") String email, diff --git a/src/main/java/com/mycom/socket/auth/dto/request/LoginRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/LoginRequest.java similarity index 92% rename from src/main/java/com/mycom/socket/auth/dto/request/LoginRequestDto.java rename to src/main/java/com/mycom/socket/auth/dto/request/LoginRequest.java index 18a035d..4c9d6ca 100644 --- a/src/main/java/com/mycom/socket/auth/dto/request/LoginRequestDto.java +++ b/src/main/java/com/mycom/socket/auth/dto/request/LoginRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -public record LoginRequestDto( +public record LoginRequest( @NotBlank(message = "이메일은 필수입니다") @Email(message = "올바른 이메일 형식이 아닙니다") String email, diff --git a/src/main/java/com/mycom/socket/auth/dto/request/RegisterRequestDto.java b/src/main/java/com/mycom/socket/auth/dto/request/RegisterRequest.java similarity index 95% rename from src/main/java/com/mycom/socket/auth/dto/request/RegisterRequestDto.java rename to src/main/java/com/mycom/socket/auth/dto/request/RegisterRequest.java index b68b904..61b51d3 100644 --- a/src/main/java/com/mycom/socket/auth/dto/request/RegisterRequestDto.java +++ b/src/main/java/com/mycom/socket/auth/dto/request/RegisterRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -public record RegisterRequestDto( +public record RegisterRequest( @NotBlank(message = "이메일은 필수입니다") @Email(message = "올바른 이메일 형식이 아닙니다") String email, diff --git a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java deleted file mode 100644 index fcd6f59..0000000 --- a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationCheckResponseDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.mycom.socket.auth.dto.response; - -import com.mycom.socket.global.dto.ApiResponse; - -public record EmailVerificationCheckResponseDto(ApiResponse apiResponse) { - - public static EmailVerificationCheckResponseDto createSuccessResponse() { - return new EmailVerificationCheckResponseDto(ApiResponse.success("이메일 인증 성공", true)); - } - - public static EmailVerificationCheckResponseDto createFailureResponse(String errorMessage) { - return new EmailVerificationCheckResponseDto(ApiResponse.error(errorMessage)); - } -} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponse.java b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponse.java new file mode 100644 index 0000000..033630b --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponse.java @@ -0,0 +1,10 @@ +package com.mycom.socket.auth.dto.response; + +public record EmailVerificationResponse( + String message +) { + public static EmailVerificationResponse of(String message) { + return new EmailVerificationResponse(message); + } +} + diff --git a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java b/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java deleted file mode 100644 index 4e78e02..0000000 --- a/src/main/java/com/mycom/socket/auth/dto/response/EmailVerificationResponseDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.mycom.socket.auth.dto.response; - -import com.mycom.socket.global.dto.ApiResponse; - -public record EmailVerificationResponseDto(ApiResponse apiResponse) { - - public static EmailVerificationResponseDto createSuccessResponse() { - return new EmailVerificationResponseDto(ApiResponse.success("이메일 전송 성공")); - } - - public static EmailVerificationResponseDto createFailureResponse(String errorMessage) { - return new EmailVerificationResponseDto(ApiResponse.error(errorMessage)); - } -} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/dto/response/LoginResponse.java b/src/main/java/com/mycom/socket/auth/dto/response/LoginResponse.java new file mode 100644 index 0000000..3f7a763 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/LoginResponse.java @@ -0,0 +1,10 @@ +package com.mycom.socket.auth.dto.response; + +public record LoginResponse( + String email, + String nickname +) { + public static LoginResponse of(String email, String nickname) { + return new LoginResponse(email, nickname); + } +} diff --git a/src/main/java/com/mycom/socket/auth/dto/response/LoginResponseDto.java b/src/main/java/com/mycom/socket/auth/dto/response/LoginResponseDto.java deleted file mode 100644 index e2cc1d4..0000000 --- a/src/main/java/com/mycom/socket/auth/dto/response/LoginResponseDto.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.mycom.socket.auth.dto.response; - -public record LoginResponseDto( - String email, - String nickname -) { - public static LoginResponseDto of(String email, String nickname) { - return new LoginResponseDto(email, nickname); - } -} diff --git a/src/main/java/com/mycom/socket/auth/dto/response/RegisterResponse.java b/src/main/java/com/mycom/socket/auth/dto/response/RegisterResponse.java new file mode 100644 index 0000000..cb29a53 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/dto/response/RegisterResponse.java @@ -0,0 +1,12 @@ +package com.mycom.socket.auth.dto.response; + +public record RegisterResponse( + Long memberId, + String email, + String nickname, + String message +) { + public static RegisterResponse of(Long memberId, String email, String nickname) { + return new RegisterResponse(memberId, email, nickname, "회원가입이 완료되었습니다."); + } +} diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java index 55680a6..f6ca338 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; @@ -15,33 +16,25 @@ import java.io.IOException; +@Slf4j @RequiredArgsConstructor public class JWTFilter extends OncePerRequestFilter { + private final JWTProperties jwtProperties; private final JWTUtil jwtUtil; private final MemberDetailsService memberDetailsService; @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - String token = resolveTokenFromCookie(request); - + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { try { + String token = resolveTokenFromCookie(request); if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { - String email = jwtUtil.getEmail(token); - UserDetails userDetails = memberDetailsService.loadUserByUsername(email); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); + setAuthentication(token); } } catch (Exception e) { + log.warn("인증 처리 실패", e); SecurityContextHolder.clearContext(); } @@ -52,11 +45,25 @@ private String resolveTokenFromCookie(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { - if ("Authorization".equals(cookie.getName())) { + if (jwtProperties.getCookieName().equals(cookie.getName())) { return cookie.getValue(); } } } return null; } + + private void setAuthentication(String token) { + String email = jwtUtil.getEmail(token); + UserDetails userDetails = memberDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } } \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java b/src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java new file mode 100644 index 0000000..ad1e8d6 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java @@ -0,0 +1,17 @@ +package com.mycom.socket.auth.jwt; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "jwt") +public class JWTProperties { + private String secret; + private long accessTokenValidityInSeconds = 1800; + private String cookieName = "Authorization"; + private String issuer = "go_socket"; + private boolean secureCookie = false; +} diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java index 2a6d57e..b7e3a3b 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java @@ -1,11 +1,10 @@ package com.mycom.socket.auth.jwt; -import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; @@ -16,42 +15,60 @@ public class JWTUtil { private final SecretKey secretKey; + private final JWTProperties jwtProperties; - public JWTUtil(@Value("${jwt.secret}") String secret) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + public JWTUtil(JWTProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.secretKey = Keys.hmacShaKeyFor( + jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8) + ); } + /** + * JWT 토큰 생성 + */ public String createToken(String email) { - Claims claims = Jwts.claims().subject(email).build(); Date now = new Date(); - // 30분 - long accessTokenValidityInMilliseconds = 1000 * 60 * 30; - Date validity = new Date(now.getTime() + accessTokenValidityInMilliseconds); + Date validity = new Date(now.getTime() + + (jwtProperties.getAccessTokenValidityInSeconds() * 1000)); return Jwts.builder() - .claims(claims) + .issuer(jwtProperties.getIssuer()) + .subject(email) .issuedAt(now) .expiration(validity) .signWith(secretKey) .compact(); } + /** + * 토큰 유효성 검증 + */ public boolean validateToken(String token) { try { + if (!StringUtils.hasText(token)) { + return false; + } + Jwts.parser() .verifyWith(secretKey) + .requireIssuer(jwtProperties.getIssuer()) .build() .parseSignedClaims(token); return true; } catch (Exception e) { - log.warn("JWT 토큰 검증 중 에러 발생: {}", e.getMessage()); + log.warn("JWT 토큰 검증 실패", e); return false; } } + /** + * 토큰에서 이메일 추출 + */ public String getEmail(String token) { return Jwts.parser() .verifyWith(secretKey) + .requireIssuer(jwtProperties.getIssuer()) .build() .parseSignedClaims(token) .getPayload() diff --git a/src/main/java/com/mycom/socket/auth/security/CookieUtil.java b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java new file mode 100644 index 0000000..422e6c1 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java @@ -0,0 +1,36 @@ +package com.mycom.socket.auth.security; + +import com.mycom.socket.auth.jwt.JWTProperties; +import jakarta.servlet.http.Cookie; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + private final JWTProperties jwtProperties; + + /** + * 인증 쿠키 생성 + */ + public Cookie createAuthCookie(String token) { + Cookie cookie = new Cookie(jwtProperties.getCookieName(), token); + cookie.setHttpOnly(true); + cookie.setSecure(jwtProperties.isSecureCookie()); + cookie.setPath("/"); + cookie.setMaxAge((int) jwtProperties.getAccessTokenValidityInSeconds()); + return cookie; + } + + /** + * 인증 쿠키 만료 처리 + */ + public Cookie createExpiredAuthCookie() { + Cookie cookie = new Cookie(jwtProperties.getCookieName(), null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(0); // 즉시 만료 + return cookie; + } +} diff --git a/src/main/java/com/mycom/socket/auth/security/LoginFilter.java b/src/main/java/com/mycom/socket/auth/security/LoginFilter.java index e3e0900..5c27f35 100644 --- a/src/main/java/com/mycom/socket/auth/security/LoginFilter.java +++ b/src/main/java/com/mycom/socket/auth/security/LoginFilter.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.global.dto.ApiResponse; -import com.mycom.socket.auth.dto.request.LoginRequestDto; -import com.mycom.socket.auth.dto.response.LoginResponseDto; +import com.mycom.socket.auth.dto.request.LoginRequest; +import com.mycom.socket.auth.dto.response.LoginResponse; import com.mycom.socket.go_socket.entity.Member; import jakarta.servlet.FilterChain; import jakarta.servlet.http.Cookie; @@ -26,13 +26,14 @@ public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final JWTUtil jwtUtil; // JwtProvider 대신 JWTUtil 사용 private final AuthenticationManager authenticationManager; + private final CookieUtil cookieUtil; private final ObjectMapper objectMapper; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { - LoginRequestDto loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class); + LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password()); @@ -44,6 +45,7 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ } } + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException { MemberDetails memberDetails = (MemberDetails) authResult.getPrincipal(); @@ -51,25 +53,17 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR String token = jwtUtil.createToken(member.getEmail()); - // HTTP Only 쿠키에 JWT 토큰 저장 - Cookie cookie = new Cookie("Authorization", token); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge(1800); // 쿠키 만료시간 30분 - - // SameSite 속성 설정 추가 - response.setHeader("Set-Cookie", - String.format("Authorization=%s; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=1800", token)); + // 쿠키 생성 및 설정 + Cookie authCookie = cookieUtil.createAuthCookie(token); + response.addCookie(authCookie); - LoginResponseDto loginResponse = new LoginResponseDto( - member.getEmail(), - member.getNickname() - ); + // 로그인 응답 생성 + LoginResponse loginResponse = new LoginResponse(member.getEmail(), member.getNickname()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); - objectMapper.writeValue(response.getWriter(), ApiResponse.success("로그인 성공", loginResponse)); + objectMapper.writeValue(response.getWriter(), loginResponse); + } @Override diff --git a/src/main/java/com/mycom/socket/auth/service/AuthService.java b/src/main/java/com/mycom/socket/auth/service/AuthService.java index 1c4e7f2..66e97ef 100644 --- a/src/main/java/com/mycom/socket/auth/service/AuthService.java +++ b/src/main/java/com/mycom/socket/auth/service/AuthService.java @@ -1,11 +1,13 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.auth.dto.response.RegisterResponse; import com.mycom.socket.auth.jwt.JWTUtil; +import com.mycom.socket.auth.security.CookieUtil; import com.mycom.socket.global.exception.BadRequestException; import com.mycom.socket.global.exception.ConflictException; -import com.mycom.socket.auth.dto.request.LoginRequestDto; -import com.mycom.socket.auth.dto.request.RegisterRequestDto; -import com.mycom.socket.auth.dto.response.LoginResponseDto; +import com.mycom.socket.auth.dto.request.LoginRequest; +import com.mycom.socket.auth.dto.request.RegisterRequest; +import com.mycom.socket.auth.dto.response.LoginResponse; import com.mycom.socket.go_socket.entity.Member; import com.mycom.socket.go_socket.entity.enums.MemberRole; import com.mycom.socket.go_socket.repository.MemberRepository; @@ -24,8 +26,19 @@ public class AuthService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final JWTUtil jwtUtil; + private final MailService mailService; + private final CookieUtil cookieUtil; - public LoginResponseDto login(LoginRequestDto request, HttpServletResponse response) { + /** + * 사용자 로그인 처리 + * 이메일과 비밀번호를 검증하고 JWT 토큰을 생성하여 쿠키에 저장 + * + * @param request 로그인 요청 정보 (이메일, 비밀번호) + * @param response HTTP 응답 객체 (쿠키 저장용) + * @return 로그인 성공 시 사용자 정보를 포함한 응답 + * @throws BadRequestException 잘못된 이메일이나 비밀번호인 경우 + */ + public LoginResponse login(LoginRequest request, HttpServletResponse response) { Member member = memberRepository.findByEmail(request.email()) .orElseThrow(() -> new BadRequestException("가입되지 않은 이메일입니다.")); @@ -34,23 +47,21 @@ public LoginResponseDto login(LoginRequestDto request, HttpServletResponse respo } String token = jwtUtil.createToken(member.getEmail()); + response.addCookie(cookieUtil.createAuthCookie(token)); // CookieUtil 사용 - // 쿠키 생성 및 설정 - Cookie cookie = new Cookie("Authorization", token); - cookie.setHttpOnly(true); // JavaScript에서 접근 불가 - cookie.setSecure(true); // HTTPS에서만 전송 - cookie.setPath("/"); // 모든 경로에서 접근 가능 - cookie.setMaxAge(1800); // 30분 - response.addCookie(cookie); - - return LoginResponseDto.of( - member.getEmail(), - member.getNickname() - ); + return LoginResponse.of(member.getEmail(), member.getNickname()); } + /** + * 회원 가입 처리 + * 이메일과 닉네임 중복 검사 후 새로운 회원 정보 저장 + * + * @param request 회원가입 요청 정보 (이메일, 비밀번호, 닉네임, 자기소개) + * @return 저장된 회원의 ID + * @throws ConflictException 이메일 또는 닉네임이 이미 존재하는 경우 + */ @Transactional - public Long register(RegisterRequestDto request) { + public RegisterResponse register(RegisterRequest request) { // 이메일 중복 검사 if (memberRepository.existsByEmail(request.email())) { throw new ConflictException("이미 존재하는 이메일입니다."); @@ -62,6 +73,9 @@ public Long register(RegisterRequestDto request) { } // 이메일 인증 여부 확인 + if (!mailService.isEmailVerified(request.email())) { + throw new BadRequestException("이메일 인증이 필요합니다. 이메일 인증을 먼저 완료해주세요."); + } Member member = Member.builder() .email(request.email()) @@ -72,15 +86,20 @@ public Long register(RegisterRequestDto request) { .build(); Member savedMember = memberRepository.save(member); - return savedMember.getId(); + return RegisterResponse.of( + savedMember.getId(), + savedMember.getEmail(), + savedMember.getNickname() + ); } + /** + * 로그아웃 처리 + * Authorization 쿠키를 무효화하여 로그아웃 처리 + * + * @param response HTTP 응답 객체 (쿠키 무효화용) + */ public void logout(HttpServletResponse response) { - Cookie cookie = new Cookie("Authorization", null); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge(0); // 즉시 만료 - response.addCookie(cookie); + response.addCookie(cookieUtil.createExpiredAuthCookie()); // CookieUtil 사용 } } diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 9a3dacd..6a2d1ec 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -1,5 +1,6 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.auth.dto.response.EmailVerificationResponse; import com.mycom.socket.auth.service.data.VerificationData; import com.mycom.socket.global.exception.BaseException; import jakarta.mail.MessagingException; @@ -41,14 +42,14 @@ private String createVerificationCode() { /** * 인증메일 생성 - * @param mail 수신자 이메일 주소 + * @param email 수신자 이메일 주소 * @return 생성된 인증메일 */ - public MimeMessage createMail(String mail, String verificationCode) { + public MimeMessage createMail(String email, String verificationCode) { MimeMessage message = javaMailSender.createMimeMessage(); try { message.setFrom(senderEmail); - message.setRecipients(MimeMessage.RecipientType.TO, mail); + message.setRecipients(MimeMessage.RecipientType.TO, email); message.setSubject("이메일 인증"); String body = String.format("""

요청하신 인증 번호입니다.

@@ -65,47 +66,58 @@ public MimeMessage createMail(String mail, String verificationCode) { /** * 인증메일 발송 및 인증번호 반환 - * @param mail 수신자 이메일 주소 + * @param email 수신자 이메일 주소 * @return 생성된 인증번호 */ - public boolean sendMail(String mail) { - rateLimiter.checkRateLimit(mail); + public EmailVerificationResponse sendMail(String email) { + rateLimiter.checkRateLimit(email); + String verificationCode = createVerificationCode(); - verificationDataMap.put(mail, new VerificationData(verificationCode)); + verificationDataMap.put(email, new VerificationData(verificationCode)); - MimeMessage message = createMail(mail, verificationCode); - try{ + MimeMessage message = createMail(email, verificationCode); + try { javaMailSender.send(message); - return true; - }catch (Exception e) { - throw new BaseException("이메일 발송 중 오류가 발생했습니다: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + return EmailVerificationResponse.of("이메일 전송 성공"); + } catch (Exception e) { + throw new BaseException("이메일 발송 중 오류가 발생했습니다: " + e.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR); } } /** * 인증번호 검증 + * * @param email 수신자 이메일 주소 - * @param code 사용자가 입력한 인증번호 + * @param code 사용자가 입력한 인증번호 * @return 인증번호 일치 여부 */ - public boolean verifyCode(String email, String code) { - if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { - return false; - } + public EmailVerificationResponse verifyCode(String email, String code) { + validateVerificationCode(code); VerificationData data = verificationDataMap.get(email); - if (data == null || data.isExpired()) { - return false; + throw new BaseException("인증 코드가 만료되었거나 존재하지 않습니다.", HttpStatus.BAD_REQUEST); } - boolean isVerified = data.code().equals(code); + if (!data.code().equals(code)) { + throw new BaseException("인증 코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); + } + + verificationDataMap.put(email, data.withVerified()); + return EmailVerificationResponse.of("이메일 인증이 완료되었습니다."); + } - if (isVerified){ - verificationDataMap.remove(email); + private void validateVerificationCode(String code) { + if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { + throw new BaseException("유효하지 않은 인증 코드 형식입니다.", HttpStatus.BAD_REQUEST); } + } - return isVerified; + public boolean isEmailVerified(String email) { + VerificationData data = verificationDataMap.get(email); + return data != null && !data.isExpired() && data.verified(); } + } diff --git a/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java index f941f16..ca343df 100644 --- a/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java +++ b/src/main/java/com/mycom/socket/auth/service/data/VerificationData.java @@ -4,15 +4,27 @@ import java.time.Duration; -public record VerificationData(String code, LocalDateTime expiryTime) { +public record VerificationData( + String code, + LocalDateTime expiryTime, + boolean verified +) { private static final Duration CODE_VALID_DURATION = Duration.ofMinutes(5); + public static VerificationData createNew(String code) { + return new VerificationData(code, LocalDateTime.now().plus(CODE_VALID_DURATION), false); + } + public VerificationData(String code) { - this(code, LocalDateTime.now().plus(CODE_VALID_DURATION)); + this(code, LocalDateTime.now().plus(CODE_VALID_DURATION), false); } public boolean isExpired() { return LocalDateTime.now().isAfter(expiryTime); } + + public VerificationData withVerified() { + return new VerificationData(this.code, this.expiryTime, true); + } } diff --git a/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java b/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java index e295323..2452fd3 100644 --- a/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java +++ b/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java @@ -3,8 +3,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mycom.socket.auth.config.SecurityConfig; import com.mycom.socket.auth.controller.AuthController; -import com.mycom.socket.auth.dto.request.RegisterRequestDto; +import com.mycom.socket.auth.dto.request.RegisterRequest; +import com.mycom.socket.auth.dto.response.RegisterResponse; +import com.mycom.socket.auth.jwt.JWTProperties; +import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.auth.service.AuthService; +import com.mycom.socket.auth.service.MailService; +import com.mycom.socket.auth.service.MemberDetailsService; +import com.mycom.socket.global.exception.BadRequestException; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -13,6 +19,8 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -33,46 +41,86 @@ class AuthControllerTest { @MockBean private AuthService authService; + @MockBean + private MailService mailService; + + @MockBean + private JWTUtil jwtUtil; + + @MockBean + private JWTProperties jwtProperties; + + @MockBean + private MemberDetailsService memberDetailsService; + + private static final String REGISTER_API_URL = "/api/auth/register"; + private static final String REGISTER_SUCCESS_MESSAGE = "회원가입이 완료되었습니다."; + private static final String EMAIL_UNVERIFIED_MESSAGE = "이메일 인증이 필요합니다."; + @Test @WithMockUser void 회원가입_성공() throws Exception { // given - RegisterRequestDto request = new RegisterRequestDto( - "test@example.com", - "testUser", - "password123", - "안녕하세요" - ); - given(authService.register(any(RegisterRequestDto.class))) - .willReturn(1L); - - // when & then - mockMvc.perform(post("/api/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) + RegisterRequest request = createRegisterRequest("test@example.com", "testUser", "password123"); + RegisterResponse expectedResponse = createRegisterResponse(1L, "test@example.com", "testUser"); + given(authService.register(any(RegisterRequest.class))).willReturn(expectedResponse); + + // when + ResultActions resultActions = performRegisterRequest(request); + + // then + resultActions .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Success")) - .andExpect(jsonPath("$.data").value(1)); + .andExpect(jsonPath("$.memberId").value(1)) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.nickname").value("testUser")) + .andExpect(jsonPath("$.message").value(REGISTER_SUCCESS_MESSAGE)); + } + + @Test + @WithMockUser + void 회원가입_실패_이메일_미인증() throws Exception { + // given + RegisterRequest request = createRegisterRequest("test@example.com", "testUser", "password123"); + given(authService.register(any(RegisterRequest.class))) + .willThrow(new BadRequestException(EMAIL_UNVERIFIED_MESSAGE)); + + // when + ResultActions resultActions = performRegisterRequest(request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(EMAIL_UNVERIFIED_MESSAGE)); } @Test @WithMockUser void 회원가입_실패_잘못된_입력값() throws Exception { // given - RegisterRequestDto request = new RegisterRequestDto( - "invalid-email", - "t", - "123", - "안녕하세요" - ); - - // when & then - mockMvc.perform(post("/api/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) + RegisterRequest request = createRegisterRequest("invalid-email", "t", "123"); + + // when + ResultActions resultActions = performRegisterRequest(request); + + // then + resultActions .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").exists()); } -} + + + private RegisterRequest createRegisterRequest(String email, String nickname, String password) { + return new RegisterRequest(email, nickname, password, "안녕하세요"); + } + + private RegisterResponse createRegisterResponse(Long memberId, String email, String nickname) { + return RegisterResponse.of(memberId, email, nickname); + } + private ResultActions performRegisterRequest(RegisterRequest request) throws Exception { + return mockMvc.perform(post(REGISTER_API_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()); + } +} \ No newline at end of file diff --git a/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java b/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java index 1fcebb9..86f7f46 100644 --- a/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java +++ b/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java @@ -1,7 +1,8 @@ package com.mycom.socket.member.service; -import com.mycom.socket.auth.dto.request.LoginRequestDto; -import com.mycom.socket.auth.dto.response.LoginResponseDto; +import com.mycom.socket.auth.dto.request.LoginRequest; +import com.mycom.socket.auth.dto.response.LoginResponse; +import com.mycom.socket.auth.jwt.JWTProperties; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.go_socket.entity.Member; import com.mycom.socket.go_socket.entity.enums.MemberRole; @@ -31,6 +32,9 @@ class LoginIntegrationTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private JWTProperties jwtProperties; + @BeforeEach void setUp() { Member testMember = Member.builder() @@ -46,12 +50,12 @@ void setUp() { @Test void 로그인통합테스트() { // given - LoginRequestDto request = new LoginRequestDto("test@test.com", "password"); + LoginRequest request = new LoginRequest("test@test.com", "password"); HttpServletResponse response = new MockHttpServletResponse(); // when - LoginResponseDto loginResponse = authService.login(request, response); - Cookie cookie = ((MockHttpServletResponse) response).getCookie("Authorization"); + LoginResponse loginResponse = authService.login(request, response); + Cookie cookie = ((MockHttpServletResponse) response).getCookie(jwtProperties.getCookieName()); // then assertAll( @@ -59,9 +63,9 @@ void setUp() { () -> assertEquals("tester", loginResponse.nickname()), () -> assertNotNull(cookie), () -> assertTrue(cookie.isHttpOnly()), - () -> assertTrue(cookie.getSecure()), + () -> assertEquals(jwtProperties.isSecureCookie(), cookie.getSecure()), () -> assertEquals("/", cookie.getPath()), - () -> assertEquals(1800, cookie.getMaxAge()) + () -> assertEquals(jwtProperties.getAccessTokenValidityInSeconds(), cookie.getMaxAge()) ); } } \ No newline at end of file diff --git a/src/test/java/com/mycom/socket/member/service/LoginTest.java b/src/test/java/com/mycom/socket/member/service/LoginTest.java index aabbbeb..bd07eb9 100644 --- a/src/test/java/com/mycom/socket/member/service/LoginTest.java +++ b/src/test/java/com/mycom/socket/member/service/LoginTest.java @@ -1,8 +1,9 @@ package com.mycom.socket.member.service; -import com.mycom.socket.auth.dto.request.LoginRequestDto; -import com.mycom.socket.auth.dto.response.LoginResponseDto; +import com.mycom.socket.auth.dto.request.LoginRequest; +import com.mycom.socket.auth.dto.response.LoginResponse; import com.mycom.socket.auth.jwt.JWTUtil; +import com.mycom.socket.auth.security.CookieUtil; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.global.exception.BadRequestException; import com.mycom.socket.go_socket.entity.Member; @@ -38,6 +39,9 @@ class LoginTest { @Mock private JWTUtil jwtUtil; + @Mock + private CookieUtil cookieUtil; + @Mock private HttpServletResponse response; @@ -50,7 +54,7 @@ class LoginTest { String encodedPassword = "encodedPassword"; String token = "test.token.here"; - LoginRequestDto request = new LoginRequestDto(email, password); + LoginRequest request = new LoginRequest(email, password); Member member = Member.builder() .email(email) .password(encodedPassword) @@ -58,32 +62,28 @@ class LoginTest { .role(MemberRole.USER) .build(); - // when + Cookie authCookie = new Cookie("Authorization", token); + authCookie.setHttpOnly(true); + authCookie.setSecure(true); + authCookie.setPath("/"); + authCookie.setMaxAge(1800); + when(memberRepository.findByEmail(email)).thenReturn(Optional.of(member)); when(passwordEncoder.matches(password, encodedPassword)).thenReturn(true); when(jwtUtil.createToken(email)).thenReturn(token); + when(cookieUtil.createAuthCookie(token)).thenReturn(authCookie); // CookieUtil 동작 정의 - LoginResponseDto response = authService.login(request, this.response); + // when + LoginResponse response = authService.login(request, this.response); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(this.response).addCookie(cookieCaptor.capture()); - Cookie cookie = cookieCaptor.getValue(); - - assertAll( - () -> assertEquals(email, response.email()), - () -> assertEquals(nickname, response.nickname()), - () -> assertEquals("Authorization", cookie.getName()), - () -> assertEquals(token, cookie.getValue()), - () -> assertTrue(cookie.isHttpOnly()), - () -> assertTrue(cookie.getSecure()), - () -> assertEquals("/", cookie.getPath()), - () -> assertEquals(1800, cookie.getMaxAge()) - ); - + verify(this.response).addCookie(authCookie); + assertEquals(email, response.email()); + assertEquals(nickname, response.nickname()); verify(memberRepository).findByEmail(email); verify(passwordEncoder).matches(password, encodedPassword); verify(jwtUtil).createToken(email); + verify(cookieUtil).createAuthCookie(token); } @Test @@ -91,7 +91,7 @@ class LoginTest { // given String email = "nonexistent@test.com"; String password = "password"; - LoginRequestDto request = new LoginRequestDto(email, password); + LoginRequest request = new LoginRequest(email, password); // when when(memberRepository.findByEmail(email)).thenReturn(Optional.empty()); @@ -108,7 +108,7 @@ class LoginTest { String email = "test@test.com"; String password = "wrongpassword"; String encodedPassword = "encodedPassword"; - LoginRequestDto request = new LoginRequestDto(email, password); + LoginRequest request = new LoginRequest(email, password); Member member = Member.builder() .email(email) diff --git a/src/test/java/com/mycom/socket/member/service/RegisterServiceTest.java b/src/test/java/com/mycom/socket/member/service/RegisterServiceTest.java index 2131fd5..4336b9d 100644 --- a/src/test/java/com/mycom/socket/member/service/RegisterServiceTest.java +++ b/src/test/java/com/mycom/socket/member/service/RegisterServiceTest.java @@ -1,6 +1,6 @@ package com.mycom.socket.member.service; -import com.mycom.socket.auth.dto.request.RegisterRequestDto; +import com.mycom.socket.auth.dto.request.RegisterRequest; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.go_socket.entity.Member; import com.mycom.socket.go_socket.repository.MemberRepository; @@ -17,6 +17,12 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.mycom.socket.global.exception.BadRequestException; +import com.mycom.socket.global.exception.ConflictException; +import com.mycom.socket.auth.service.MailService; +import com.mycom.socket.auth.dto.response.RegisterResponse; + @ExtendWith(MockitoExtension.class) class RegisterServiceTest { @@ -30,10 +36,13 @@ class RegisterServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private MailService mailService; // 추가 필요 + @Test void 회원가입_성공() { // given - RegisterRequestDto request = new RegisterRequestDto( + RegisterRequest request = new RegisterRequest( "test@example.com", "testUser", "password123", @@ -43,24 +52,66 @@ class RegisterServiceTest { given(memberRepository.existsByEmail(request.email())).willReturn(false); given(memberRepository.existsByNickname(request.nickname())).willReturn(false); given(passwordEncoder.encode(request.password())).willReturn("encodedPassword"); + given(mailService.isEmailVerified(request.email())).willReturn(true); // 이메일 인증 확인 - // save()를 호출했을 때 반환할 Member 객체 설정 Member savedMember = Member.builder() .email(request.email()) .nickname(request.nickname()) .password("encodedPassword") .intro(request.intro()) .build(); - // ID 설정 (실제로는 DB가 생성) ReflectionTestUtils.setField(savedMember, "id", 1L); given(memberRepository.save(any(Member.class))).willReturn(savedMember); // when - Long memberId = authService.register(request); + RegisterResponse response = authService.register(request); // then - assertThat(memberId).isEqualTo(1L); + assertThat(response.memberId()).isEqualTo(1L); + assertThat(response.email()).isEqualTo(request.email()); + assertThat(response.nickname()).isEqualTo(request.nickname()); + assertThat(response.message()).isEqualTo("회원가입이 완료되었습니다."); + verify(memberRepository).save(any(Member.class)); + verify(mailService).isEmailVerified(request.email()); + } + + @Test + void 회원가입_실패_이메일_미인증() { + // given + RegisterRequest request = new RegisterRequest( + "test@example.com", + "testUser", + "password123", + "안녕하세요" + ); + + given(memberRepository.existsByEmail(request.email())).willReturn(false); + given(memberRepository.existsByNickname(request.nickname())).willReturn(false); + given(mailService.isEmailVerified(request.email())).willReturn(false); + + // when & then + assertThatThrownBy(() -> authService.register(request)) + .isInstanceOf(BadRequestException.class) + .hasMessage("이메일 인증이 필요합니다. 이메일 인증을 먼저 완료해주세요."); + } + + @Test + void 회원가입_실패_이메일_중복() { + // given + RegisterRequest request = new RegisterRequest( + "test@example.com", + "testUser", + "password123", + "안녕하세요" + ); + + given(memberRepository.existsByEmail(request.email())).willReturn(true); + + // when & then + assertThatThrownBy(() -> authService.register(request)) + .isInstanceOf(ConflictException.class) + .hasMessage("이미 존재하는 이메일입니다."); } }