diff --git a/.env b/.env deleted file mode 100644 index 9129161..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -SPRING_PROFILES_ACTIVE=local -TZ=Asia/Seoul - -DB_URL=jdbc:mysql://localhost:3306/umc_9th -DB_USER=root -DB_PW=1234 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd2911a..caa24b2 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ docker-compose.override.yml node_modules/ dist/ +.env diff --git a/build.gradle b/build.gradle index 46f8dee..eca2d56 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,17 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/main/java/com/umc/umc9th/HelloController.java b/src/main/java/com/umc/umc9th/HelloController.java deleted file mode 100644 index 2cf86e9..0000000 --- a/src/main/java/com/umc/umc9th/HelloController.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.umc.umc9th; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class HelloController { - @GetMapping("/") - public String home() { return "OK"; } -} - diff --git a/src/main/java/com/umc/umc9th/domain/auth/controller/AuthController.java b/src/main/java/com/umc/umc9th/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..ce99cfe --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/controller/AuthController.java @@ -0,0 +1,57 @@ +package com.umc.umc9th.domain.auth.controller; + +import com.umc.umc9th.domain.auth.jwt.dto.JWTResponseDTO; +import com.umc.umc9th.domain.auth.jwt.dto.RefreshRequestDTO; +import com.umc.umc9th.domain.auth.jwt.service.JwtService; +import com.umc.umc9th.domain.auth.service.LoginService; +import com.umc.umc9th.domain.user.dto.request.LoginRequest; +import com.umc.umc9th.domain.user.dto.request.SignUpRequest; +import com.umc.umc9th.domain.user.dto.response.AuthResponse; +import com.umc.umc9th.domain.user.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "인증 API", description = "회원가입, 로그인, 토큰 관리 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final LoginService loginService; + private final JwtService jwtService; + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다. 이메일은 중복될 수 없습니다." + ) + @PostMapping("/signup") + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request) { + AuthResponse response = authService.signUp(request); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "로그인", + description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다." + ) + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + JWTResponseDTO response = loginService.login(request); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "액세스 토큰 갱신", + description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. (Refresh Token Rotation)" + ) + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody RefreshRequestDTO request) { + JWTResponseDTO response = jwtService.refreshRotate(request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/filter/JWTFilter.java b/src/main/java/com/umc/umc9th/domain/auth/filter/JWTFilter.java new file mode 100644 index 0000000..580babc --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/filter/JWTFilter.java @@ -0,0 +1,55 @@ +package com.umc.umc9th.domain.auth.filter; + +import com.umc.umc9th.domain.auth.jwt.JWTProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public class JWTFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String authorization = request.getHeader("Authorization"); + if (authorization == null) { + filterChain.doFilter(request, response); + return; + } + + if (!authorization.startsWith("Bearer ")) { + throw new ServletException("Invalid JWT token"); + } + + // 토큰 파싱 + String accessToken = authorization.split(" ")[1]; + + if (JWTProvider.isValid(accessToken, true)) { + + String userEmail = JWTProvider.getUserEmail(accessToken); + String role = JWTProvider.getRole(accessToken); + + List authorities = Collections.singletonList(new SimpleGrantedAuthority(role)); + + Authentication auth = new UsernamePasswordAuthenticationToken(userEmail, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + filterChain.doFilter(request, response); + + } else { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"토큰 만료 또는 유효하지 않은 토큰\"}"); + } + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/filter/LoginFilter.java b/src/main/java/com/umc/umc9th/domain/auth/filter/LoginFilter.java new file mode 100644 index 0000000..c595156 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/filter/LoginFilter.java @@ -0,0 +1,68 @@ +package com.umc.umc9th.domain.auth.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.umc9th.domain.user.dto.request.LoginRequest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; + +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final ObjectMapper objectMapper; + + public LoginFilter(AuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + this.objectMapper = new ObjectMapper(); + setFilterProcessesUrl("/api/auth/login"); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + // POST 메소드가 아니면 예외 발생 + if (!request.getMethod().equals("POST")) { + throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); + } + + try { + LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + loginRequest.userEmail(), + loginRequest.password() + ); + + return authenticationManager.authenticate(authToken); + + } catch (IOException e) { + throw new RuntimeException("로그인 요청 파싱 실패", e); + } + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, Authentication authResult) throws IOException { + + // LoginSuccessHandler에서 처리 + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"로그인 실패\"}"); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/handler/LoginSuccessHandler.java b/src/main/java/com/umc/umc9th/domain/auth/handler/LoginSuccessHandler.java new file mode 100644 index 0000000..b2673d0 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/handler/LoginSuccessHandler.java @@ -0,0 +1,52 @@ +package com.umc.umc9th.domain.auth.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc.umc9th.domain.auth.jwt.JWTProvider; +import com.umc.umc9th.domain.auth.jwt.dto.JWTResponseDTO; +import com.umc.umc9th.domain.auth.jwt.service.JwtService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +@Component +@RequiredArgsConstructor +public class LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtService jwtService; + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + String userEmail = authentication.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // JWT 토큰 생성 + String accessToken = JWTProvider.createJWT(userEmail, role, true); + String refreshToken = JWTProvider.createJWT(userEmail, role, false); + + // Refresh 토큰 DB 저장 + jwtService.addRefresh(userEmail, refreshToken); + + // 응답 + JWTResponseDTO jwtResponse = new JWTResponseDTO(accessToken, refreshToken); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(jwtResponse)); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/handler/RefreshTokenLogoutHandler.java b/src/main/java/com/umc/umc9th/domain/auth/handler/RefreshTokenLogoutHandler.java new file mode 100644 index 0000000..7753a44 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/handler/RefreshTokenLogoutHandler.java @@ -0,0 +1,26 @@ +package com.umc.umc9th.domain.auth.handler; + +import com.umc.umc9th.domain.auth.jwt.service.JwtService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenLogoutHandler implements LogoutHandler { + + private final JwtService jwtService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + String refreshToken = request.getHeader("RefreshToken"); + + if (refreshToken != null && jwtService.existsRefresh(refreshToken)) { + jwtService.removeRefresh(refreshToken); + } + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/JWTProvider.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/JWTProvider.java new file mode 100644 index 0000000..4434f95 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/JWTProvider.java @@ -0,0 +1,94 @@ +package com.umc.umc9th.domain.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTProvider { + + private static String secretKeyString; + private static Long accessTokenExpiresIn; + private static Long refreshTokenExpiresIn; + + private static SecretKey secretKey; + + public JWTProvider( + @Value("${jwt.secret}") String secretKeyString, + @Value("${jwt.access-token-validity}") Long accessTokenExpiresIn, + @Value("${jwt.refresh-token-validity}") Long refreshTokenExpiresIn + ) { + JWTProvider.secretKeyString = secretKeyString; + JWTProvider.accessTokenExpiresIn = accessTokenExpiresIn; + JWTProvider.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + @PostConstruct + private void init() { + secretKey = new SecretKeySpec( + secretKeyString.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + // JWT 클레임 userEmail 파싱 + public static String getUserEmail(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .get("sub", String.class); + } + + // JWT 클레임 role 파싱 + public static String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .get("role", String.class); + } + + // JWT 유효 여부 (위조, 시간, Access/Refresh 여부) + public static Boolean isValid(String token, Boolean isAccess) { + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + String type = claims.get("type", String.class); + if (type == null) return false; + + if (isAccess && !type.equals("access")) return false; + if (!isAccess && !type.equals("refresh")) return false; + + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + // JWT(Access/Refresh) 생성 + public static String createJWT(String userEmail, String role, Boolean isAccess) { + long now = System.currentTimeMillis(); + long expiry = isAccess ? accessTokenExpiresIn : refreshTokenExpiresIn; + String type = isAccess ? "access" : "refresh"; + + return Jwts.builder() + .claim("sub", userEmail) + .claim("role", role) + .claim("type", type) + .issuedAt(new Date(now)) + .expiration(new Date(now + expiry)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/JWTResponseDTO.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/JWTResponseDTO.java new file mode 100644 index 0000000..c87aff0 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/JWTResponseDTO.java @@ -0,0 +1,13 @@ +package com.umc.umc9th.domain.auth.jwt.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "JWT 토큰 응답 DTO") +public record JWTResponseDTO( + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String refreshToken +) { +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/RefreshRequestDTO.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/RefreshRequestDTO.java new file mode 100644 index 0000000..c9c7ed9 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/dto/RefreshRequestDTO.java @@ -0,0 +1,9 @@ +package com.umc.umc9th.domain.auth.jwt.dto; + +public record RefreshRequestDTO( + String refreshToken +) { + public String getRefreshToken() { + return refreshToken; + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/entity/RefreshEntity.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/entity/RefreshEntity.java new file mode 100644 index 0000000..0089d1a --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/entity/RefreshEntity.java @@ -0,0 +1,25 @@ +package com.umc.umc9th.domain.auth.jwt.entity; + +import com.umc.umc9th.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "refresh_token") +public class RefreshEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "refresh_id") + private Long id; + + @Column(name = "user_email", nullable = false, length = 255) + private String userEmail; + + @Column(name = "refresh", nullable = false, length = 512) + private String refresh; +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/repository/RefreshRepository.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/repository/RefreshRepository.java new file mode 100644 index 0000000..05270e3 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/repository/RefreshRepository.java @@ -0,0 +1,15 @@ +package com.umc.umc9th.domain.auth.jwt.repository; + +import com.umc.umc9th.domain.auth.jwt.entity.RefreshEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshRepository extends JpaRepository { + + Boolean existsByRefresh(String refresh); + + void deleteByRefresh(String refresh); + + void deleteByUserEmail(String userEmail); +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/jwt/service/JwtService.java b/src/main/java/com/umc/umc9th/domain/auth/jwt/service/JwtService.java new file mode 100644 index 0000000..b0b7307 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/jwt/service/JwtService.java @@ -0,0 +1,79 @@ +package com.umc.umc9th.domain.auth.jwt.service; + +import com.umc.umc9th.domain.auth.jwt.JWTProvider; +import com.umc.umc9th.domain.auth.jwt.dto.JWTResponseDTO; +import com.umc.umc9th.domain.auth.jwt.dto.RefreshRequestDTO; +import com.umc.umc9th.domain.auth.jwt.entity.RefreshEntity; +import com.umc.umc9th.domain.auth.jwt.repository.RefreshRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class JwtService { + + private final RefreshRepository refreshRepository; + + // Refresh 토큰으로 Access 토큰 재발급 로직 (Rotate 포함) + @Transactional + public JWTResponseDTO refreshRotate(RefreshRequestDTO dto) { + + String refreshToken = dto.getRefreshToken(); + + // Refresh 토큰 검증 + Boolean isValid = JWTProvider.isValid(refreshToken, false); + if (!isValid) { + throw new RuntimeException("유효하지 않은 refreshToken입니다."); + } + + // 정보 추출 + String userEmail = JWTProvider.getUserEmail(refreshToken); + String role = JWTProvider.getRole(refreshToken); + + // 토큰 생성 + String newAccessToken = JWTProvider.createJWT(userEmail, role, true); + String newRefreshToken = JWTProvider.createJWT(userEmail, role, false); + + // 기존 Refresh 토큰 DB 삭제 후 신규 추가 + RefreshEntity newRefreshEntity = RefreshEntity.builder() + .userEmail(userEmail) + .refresh(newRefreshToken) + .build(); + + removeRefresh(refreshToken); + refreshRepository.save(newRefreshEntity); + + return new JWTResponseDTO(newAccessToken, newRefreshToken); + } + + // JWT Refresh 토큰 발급 후 저장 메소드 + @Transactional + public void addRefresh(String userEmail, String refreshToken) { + + RefreshEntity entity = RefreshEntity.builder() + .userEmail(userEmail) + .refresh(refreshToken) + .build(); + + refreshRepository.save(entity); + } + + // JWT Refresh 존재 확인 메소드 + @Transactional(readOnly = true) + public Boolean existsRefresh(String refreshToken) { + return refreshRepository.existsByRefresh(refreshToken); + } + + // JWT Refresh 토큰 삭제 메소드 + @Transactional + public void removeRefresh(String refreshToken) { + refreshRepository.deleteByRefresh(refreshToken); + } + + // 특정 유저 Refresh 토큰 모두 삭제 (탈퇴) + @Transactional + public void removeRefreshUser(String userEmail) { + refreshRepository.deleteByUserEmail(userEmail); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/service/CustomUserDetailsService.java b/src/main/java/com/umc/umc9th/domain/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..6c71b5c --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/service/CustomUserDetailsService.java @@ -0,0 +1,41 @@ +package com.umc.umc9th.domain.auth.service; + +import com.umc.umc9th.domain.user.entity.User; +import com.umc.umc9th.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String userEmail) throws UsernameNotFoundException { + User user = userRepository.findByUserEmail(userEmail) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + userEmail)); + + if (!user.getIsActive()) { + throw new RuntimeException("비활성화된 사용자입니다."); + } + + List authorities = Collections.singletonList( + new SimpleGrantedAuthority(user.getRole().name()) + ); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUserEmail()) + .password(user.getPassword()) + .authorities(authorities) + .build(); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/auth/service/LoginService.java b/src/main/java/com/umc/umc9th/domain/auth/service/LoginService.java new file mode 100644 index 0000000..9380919 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/auth/service/LoginService.java @@ -0,0 +1,54 @@ +package com.umc.umc9th.domain.auth.service; + +import com.umc.umc9th.domain.auth.jwt.JWTProvider; +import com.umc.umc9th.domain.auth.jwt.dto.JWTResponseDTO; +import com.umc.umc9th.domain.auth.jwt.service.JwtService; +import com.umc.umc9th.domain.user.dto.request.LoginRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Iterator; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LoginService { + + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + + @Transactional + public JWTResponseDTO login(LoginRequest request) { + // 인증 시도 + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + request.userEmail(), + request.password() + ); + + Authentication authentication = authenticationManager.authenticate(authToken); + + // 인증 성공 - JWT 토큰 생성 + String userEmail = authentication.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // JWT 토큰 생성 + String accessToken = JWTProvider.createJWT(userEmail, role, true); + String refreshToken = JWTProvider.createJWT(userEmail, role, false); + + // Refresh 토큰 DB 저장 + jwtService.addRefresh(userEmail, refreshToken); + + return new JWTResponseDTO(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/umc/umc9th/domain/user/dto/request/LoginRequest.java b/src/main/java/com/umc/umc9th/domain/user/dto/request/LoginRequest.java new file mode 100644 index 0000000..876c10f --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/dto/request/LoginRequest.java @@ -0,0 +1,16 @@ +package com.umc.umc9th.domain.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "로그인 요청 DTO") +public record LoginRequest( + @Schema(description = "사용자 이메일", example = "user@example.com") + @NotBlank(message = "이메일은 필수입니다.") + String userEmail, + + @Schema(description = "비밀번호", example = "Test1234!@") + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} \ No newline at end of file diff --git a/src/main/java/com/umc/umc9th/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/umc/umc9th/domain/user/dto/request/SignUpRequest.java new file mode 100644 index 0000000..4fd69c5 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/dto/request/SignUpRequest.java @@ -0,0 +1,37 @@ +package com.umc.umc9th.domain.user.dto.request; + +import com.umc.umc9th.domain.user.entity.Gender; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public record SignUpRequest( + @NotBlank(message = "사용자 이름은 필수입니다.") + String userName, + + @NotBlank(message = "비밀번호는 필수입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$", + message = "비밀번호는 8자 이상, 영문, 숫자, 특수문자를 포함해야 합니다.") + String password, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String userEmail, + + @NotBlank(message = "닉네임은 필수입니다.") + String userNickname, + + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$", + message = "올바른 전화번호 형식이 아닙니다.") + String userPhone, + + @NotNull(message = "성별은 필수입니다.") + Gender userGender, + + LocalDateTime userBirth +) { +} \ No newline at end of file diff --git a/src/main/java/com/umc/umc9th/domain/user/dto/response/AuthResponse.java b/src/main/java/com/umc/umc9th/domain/user/dto/response/AuthResponse.java new file mode 100644 index 0000000..39958a0 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/dto/response/AuthResponse.java @@ -0,0 +1,13 @@ +package com.umc.umc9th.domain.user.dto.response; + +import com.umc.umc9th.domain.user.entity.Role; + +public record AuthResponse( + Long userId, + String userName, + String userEmail, + String userNickname, + Role role, + String message +) { +} \ No newline at end of file diff --git a/src/main/java/com/umc/umc9th/domain/user/entity/Role.java b/src/main/java/com/umc/umc9th/domain/user/entity/Role.java new file mode 100644 index 0000000..1f4fcd0 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/entity/Role.java @@ -0,0 +1,6 @@ +package com.umc.umc9th.domain.user.entity; + +public enum Role { + ROLE_ADMIN, + ROLE_USER +} diff --git a/src/main/java/com/umc/umc9th/domain/user/entity/User.java b/src/main/java/com/umc/umc9th/domain/user/entity/User.java index 6be0fbc..b6b984e 100644 --- a/src/main/java/com/umc/umc9th/domain/user/entity/User.java +++ b/src/main/java/com/umc/umc9th/domain/user/entity/User.java @@ -31,6 +31,13 @@ public class User extends BaseEntity { @Column(name = "user_name", nullable = false, length = 100) private String userName; + @Enumerated(EnumType.STRING) + private Role role; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) @Column(name = "user_gender", nullable = false, length = 20) private Gender userGender; diff --git a/src/main/java/com/umc/umc9th/domain/user/repository/UserRepository.java b/src/main/java/com/umc/umc9th/domain/user/repository/UserRepository.java index 137d0f1..a978563 100644 --- a/src/main/java/com/umc/umc9th/domain/user/repository/UserRepository.java +++ b/src/main/java/com/umc/umc9th/domain/user/repository/UserRepository.java @@ -28,4 +28,10 @@ public interface UserRepository extends JpaRepository { Optional findMyPageById(@Param("userId") Long userId); Long searchById(Long id); + + Optional findByUserEmail(String userEmail); + + Boolean existsByUserEmail(String userEmail); + + Boolean existsByUserNickname(String userNickname); } diff --git a/src/main/java/com/umc/umc9th/domain/user/service/AuthService.java b/src/main/java/com/umc/umc9th/domain/user/service/AuthService.java new file mode 100644 index 0000000..22827a1 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/service/AuthService.java @@ -0,0 +1,59 @@ +package com.umc.umc9th.domain.user.service; + +import com.umc.umc9th.domain.user.dto.request.SignUpRequest; +import com.umc.umc9th.domain.user.dto.response.AuthResponse; +import com.umc.umc9th.domain.user.entity.Role; +import com.umc.umc9th.domain.user.entity.User; +import com.umc.umc9th.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public AuthResponse signUp(SignUpRequest request) { + // 이메일 중복 확인 + if (userRepository.existsByUserEmail(request.userEmail())) { + throw new RuntimeException("이미 존재하는 이메일입니다."); + } + + // 닉네임 중복 확인 + if (userRepository.existsByUserNickname(request.userNickname())) { + throw new RuntimeException("이미 존재하는 닉네임입니다."); + } + + // User 엔티티 생성 + User user = User.builder() + .userName(request.userName()) + .password(passwordEncoder.encode(request.password())) + .userEmail(request.userEmail()) + .userNickname(request.userNickname()) + .userPhone(request.userPhone()) + .userGender(request.userGender()) + .userBirth(request.userBirth()) + .role(Role.ROLE_USER) + .userPoint(0L) + .isActive(true) + .isSocial(false) + .build(); + + User savedUser = userRepository.save(user); + + return new AuthResponse( + savedUser.getId(), + savedUser.getUserName(), + savedUser.getUserEmail(), + savedUser.getUserNickname(), + savedUser.getRole(), + "회원가입이 완료되었습니다." + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/umc/umc9th/domain/user/service/UserAuthService.java b/src/main/java/com/umc/umc9th/domain/user/service/UserAuthService.java new file mode 100644 index 0000000..ffc3839 --- /dev/null +++ b/src/main/java/com/umc/umc9th/domain/user/service/UserAuthService.java @@ -0,0 +1,59 @@ +package com.umc.umc9th.domain.user.service; + +import com.umc.umc9th.domain.user.dto.request.SignUpRequest; +import com.umc.umc9th.domain.user.dto.response.AuthResponse; +import com.umc.umc9th.domain.user.entity.Role; +import com.umc.umc9th.domain.user.entity.User; +import com.umc.umc9th.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserAuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public AuthResponse signUp(SignUpRequest request) { + // 이메일 중복 확인 + if (userRepository.existsByUserEmail(request.userEmail())) { + throw new RuntimeException("이미 존재하는 이메일입니다."); + } + + // 닉네임 중복 확인 + if (userRepository.existsByUserNickname(request.userNickname())) { + throw new RuntimeException("이미 존재하는 닉네임입니다."); + } + + // User 엔티티 생성 + User user = User.builder() + .userName(request.userName()) + .password(passwordEncoder.encode(request.password())) + .userEmail(request.userEmail()) + .userNickname(request.userNickname()) + .userPhone(request.userPhone()) + .userGender(request.userGender()) + .userBirth(request.userBirth()) + .role(Role.ROLE_USER) + .userPoint(0L) + .isActive(true) + .isSocial(false) + .build(); + + User savedUser = userRepository.save(user); + + return new AuthResponse( + savedUser.getId(), + savedUser.getUserName(), + savedUser.getUserEmail(), + savedUser.getUserNickname(), + savedUser.getRole(), + "회원가입이 완료되었습니다." + ); + } +} diff --git a/src/main/java/com/umc/umc9th/global/config/SecurityConfig.java b/src/main/java/com/umc/umc9th/global/config/SecurityConfig.java new file mode 100644 index 0000000..d321b2b --- /dev/null +++ b/src/main/java/com/umc/umc9th/global/config/SecurityConfig.java @@ -0,0 +1,91 @@ +package com.umc.umc9th.global.config; + +import com.umc.umc9th.domain.auth.filter.JWTFilter; +import com.umc.umc9th.domain.auth.handler.RefreshTokenLogoutHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final RefreshTokenLogoutHandler refreshTokenLogoutHandler; + + private final String[] allowUris = { + // 기본 경로 + "/", + "/error", + // 인증 관련 + "/api/auth/signup", + "/api/auth/login", + "/api/auth/refresh", + // Swagger 허용 + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/v3/api-docs/**", + "/webjars/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http + // CSRF 비활성화 (JWT 사용) + .csrf(csrf -> csrf.disable()) + + // Form 로그인 비활성화 + .formLogin(form -> form.disable()) + + // HTTP Basic 비활성화 + .httpBasic(basic -> basic.disable()) + + // 세션 사용 안 함 (Stateless) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 권한 설정 + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + + // 로그아웃 설정 + .logout(logout -> logout + .logoutUrl("/api/auth/logout") + .addLogoutHandler(refreshTokenLogoutHandler) + .logoutSuccessHandler((request, response, authentication) -> { + response.setStatus(200); + }) + ) + + // JWT 필터 추가 + .addFilterBefore(new JWTFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager() throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/umc/umc9th/global/controller/HomeController.java b/src/main/java/com/umc/umc9th/global/controller/HomeController.java new file mode 100644 index 0000000..30888aa --- /dev/null +++ b/src/main/java/com/umc/umc9th/global/controller/HomeController.java @@ -0,0 +1,13 @@ +package com.umc.umc9th.global.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @GetMapping("/") + public String redirectToSwagger() { + return "redirect:/swagger-ui/index.html"; + } +} diff --git a/src/main/java/com/umc/umc9th/global/error/exception/CustomExceptionHandler.java b/src/main/java/com/umc/umc9th/global/error/exception/CustomExceptionHandler.java index 4c460a8..57dea98 100644 --- a/src/main/java/com/umc/umc9th/global/error/exception/CustomExceptionHandler.java +++ b/src/main/java/com/umc/umc9th/global/error/exception/CustomExceptionHandler.java @@ -8,6 +8,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -35,6 +37,23 @@ public ResponseEntity handleAccessDeniedException(AccessDeniedException .body("접근 권한이 없습니다."); } + // Spring Security 인증 예외 처리 + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException e) { + log.error("BadCredentialsException : {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("이메일 또는 비밀번호가 올바르지 않습니다."); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + log.error("AuthenticationException : {}", e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("인증에 실패했습니다: " + e.getMessage()); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException(RuntimeException e) { log.error("Exception : {}", e.getMessage(), e); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0216dad..58ec4f1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,9 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + +jwt: + secret: ${JWT_TOKEN_KEY} + access-token-validity: 3600000 # 1시간 (밀리초) + refresh-token-validity: 604800000 # 7일 (밀리초) \ No newline at end of file