diff --git a/build.gradle b/build.gradle index ff04421..c4e5bfb 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' + //security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // util compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -56,6 +64,10 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/db-compose.yml b/db-compose.yml new file mode 100644 index 0000000..5688bb6 --- /dev/null +++ b/db-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.3 + container_name: hanipman-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: hanipman + MYSQL_USER: hanipman + MYSQL_PASSWORD: 1234 + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql.cnf:/etc/mysql/conf.d/my.cnf + - ./initdb/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + platform: linux/x86_64 + entrypoint: ["/bin/sh", "-c", "chmod 644 /etc/mysql/conf.d/my.cnf && docker-entrypoint.sh mysqld"] + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: \ No newline at end of file diff --git a/src/main/java/ita/growin/domain/auth/controller/AuthController.java b/src/main/java/ita/growin/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..a271ef0 --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/controller/AuthController.java @@ -0,0 +1,86 @@ +package ita.growin.domain.auth.controller; + +import ita.growin.domain.auth.dto.request.KakaoLoginRequest; +import ita.growin.domain.auth.dto.request.KakaoSignupRequest; +import ita.growin.domain.auth.dto.request.RefreshTokenRequest; +import ita.growin.domain.auth.dto.response.AuthResponse; +import ita.growin.domain.auth.service.AuthService; +import ita.growin.global.response.APIResponse; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + @Value("${kakao.client-id}") + private String kakaoClientId; + + @Value("${kakao.redirect-uri}") + private String kakaoRedirectUri; + + private final AuthService authService; + + @GetMapping("/kakao") + public ResponseEntity> kakaoLogin() throws IOException { + String kakaoAuthUrl = UriComponentsBuilder + .fromUriString("https://kauth.kakao.com/oauth/authorize") + .queryParam("client_id", kakaoClientId) + .queryParam("redirect_uri", kakaoRedirectUri) + .queryParam("response_type", "code") + .queryParam("scope", "profile_nickname,account_email") + .build() + .toUriString(); + + Map response = new HashMap<>(); + response.put("url", kakaoAuthUrl); + + return ResponseEntity.ok(response); + } + + @PostMapping("/kakao/signup") + public ResponseEntity> kakaoSignup( + @Valid @RequestBody KakaoSignupRequest request + ) { + AuthResponse response = authService.kakaoSignup(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(APIResponse.success(response)); + } + + @PostMapping("/kakao/login") + public ResponseEntity> kakaoLogin( + @Valid @RequestBody KakaoLoginRequest request + ) { + AuthResponse response = authService.kakaoLogin(request); + return ResponseEntity.ok(APIResponse.success(response)); + } + + @PostMapping("/refresh") + public ResponseEntity> refreshToken( + @Valid @RequestBody RefreshTokenRequest request + ) { + AuthResponse response = authService.refreshToken(request); + return ResponseEntity.ok(APIResponse.success(response)); + } + + @PostMapping("/logout") + public ResponseEntity> logout( + @RequestAttribute("userId") Long userId + ) { + authService.logout(userId); + return ResponseEntity.ok(APIResponse.success(null)); + } +} + diff --git a/src/main/java/ita/growin/domain/auth/dto/request/KakaoLoginRequest.java b/src/main/java/ita/growin/domain/auth/dto/request/KakaoLoginRequest.java new file mode 100644 index 0000000..1d4213b --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/request/KakaoLoginRequest.java @@ -0,0 +1,15 @@ +package ita.growin.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; + +import lombok.Getter; + + +@Getter +public class KakaoLoginRequest { + + @NotBlank(message = "Access Token은 필수입니다.") + private String accessToken; + + private String deviceToken; +} \ No newline at end of file diff --git a/src/main/java/ita/growin/domain/auth/dto/request/KakaoSignupRequest.java b/src/main/java/ita/growin/domain/auth/dto/request/KakaoSignupRequest.java new file mode 100644 index 0000000..6d751ad --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/request/KakaoSignupRequest.java @@ -0,0 +1,27 @@ +package ita.growin.domain.auth.dto.request; + +import ita.growin.domain.user.constant.InterestField; +import ita.growin.domain.user.constant.Target; +import ita.growin.domain.user.constant.Work; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; + +import lombok.Getter; + +@Getter +public class KakaoSignupRequest { + + @NotBlank(message = "Code는 필수입니다.") + private String code; + + @NotNull(message = "동네 정보는 필수입니다.") + private String location; + + @NotNull(message = "닉네임은 필수입니다.") + private String nickname; + + @NotNull(message = "전화번호는 필수입니다.") + private String phone; + + private String deviceToken; +} \ No newline at end of file diff --git a/src/main/java/ita/growin/domain/auth/dto/request/RefreshTokenRequest.java b/src/main/java/ita/growin/domain/auth/dto/request/RefreshTokenRequest.java new file mode 100644 index 0000000..7e13fbf --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/request/RefreshTokenRequest.java @@ -0,0 +1,10 @@ +package ita.growin.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class RefreshTokenRequest { + @NotBlank(message = "Refresh Token은 필수입니다.") + private String refreshToken; +} diff --git a/src/main/java/ita/growin/domain/auth/dto/response/AuthResponse.java b/src/main/java/ita/growin/domain/auth/dto/response/AuthResponse.java new file mode 100644 index 0000000..83875dd --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/response/AuthResponse.java @@ -0,0 +1,15 @@ +package ita.growin.domain.auth.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class AuthResponse { + private String accessToken; + private String refreshToken; + private String tokenType; + private Long expiresIn; + private UserDto user; +} + diff --git a/src/main/java/ita/growin/domain/auth/dto/response/KakaoAuthToken.java b/src/main/java/ita/growin/domain/auth/dto/response/KakaoAuthToken.java new file mode 100644 index 0000000..a1de7d6 --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/response/KakaoAuthToken.java @@ -0,0 +1,18 @@ +package ita.growin.domain.auth.dto.response; + +import lombok.Getter; + +@Getter +public class KakaoAuthToken { + /** 토큰 요청에 필요한 인가 코드 */ + private String code; + + /** 인증 실패 시 반환되는 에러 코드 */ + private String error; + + /** 인증 실패 시 반환되는 에러 메시지 */ + private String error_description; + + /** CSRF 방지용 state 값 (선택적으로 사용) */ + private String state; +} diff --git a/src/main/java/ita/growin/domain/auth/dto/response/KakaoTokenResponse.java b/src/main/java/ita/growin/domain/auth/dto/response/KakaoTokenResponse.java new file mode 100644 index 0000000..19c10ad --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/response/KakaoTokenResponse.java @@ -0,0 +1,24 @@ +package ita.growin.domain.auth.dto.response; + +import lombok.Getter; + +@Getter +public class KakaoTokenResponse { + /** 토큰 타입 (보통 "bearer") */ + private String token_type; + + /** 사용자 액세스 토큰 */ + private String access_token; + + /** 액세스 토큰 만료 시간(초 단위) */ + private Integer expires_in; + + /** 리프레시 토큰 */ + private String refresh_token; + + /** 리프레시 토큰 만료 시간(초 단위) */ + private Integer refresh_token_expires_in; + + /** 인증된 정보 범위(scope). 공백으로 구분됨 */ + private String scope; +} diff --git a/src/main/java/ita/growin/domain/auth/dto/response/UserDto.java b/src/main/java/ita/growin/domain/auth/dto/response/UserDto.java new file mode 100644 index 0000000..8fcb198 --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/dto/response/UserDto.java @@ -0,0 +1,38 @@ +package ita.growin.domain.auth.dto.response; + +import ita.growin.domain.user.constant.*; +import ita.growin.domain.user.entity.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +@Builder +public class UserDto { + private Long userId; + private String email; + private String nickname; + private LoginType type; + private UserStatus status; + private String location; + private String phone; + private LocalDateTime createdAt; + private Boolean isNewUser; + + public static UserDto from(User user) { + return UserDto.builder() + .userId(user.getUserId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .type(user.getType()) + .status(user.getStatus()) + .phone(user.getPhone()) + .location(user.getLocation()) + .createdAt(user.getCreatedAt()) + .isNewUser(false) + .build(); + } +} diff --git a/src/main/java/ita/growin/domain/auth/entity/JwtTokenProvider.java b/src/main/java/ita/growin/domain/auth/entity/JwtTokenProvider.java new file mode 100644 index 0000000..4599edc --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/entity/JwtTokenProvider.java @@ -0,0 +1,97 @@ +package ita.growin.domain.auth.entity; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import ita.growin.domain.user.entity.User; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final Key key; + private final long accessTokenValidity; + private final long refreshTokenValidity; + + public JwtTokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity}") long accessTokenValidity, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidity + ) { + this.key = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenValidity = accessTokenValidity; + this.refreshTokenValidity = refreshTokenValidity; + } + + // Access Token 생성 + public String generateAccessToken(User user) { + Date now = new Date(); + Date validity = new Date(now.getTime() + accessTokenValidity); + + return Jwts.builder() + .setSubject(user.getUserId().toString()) + .claim("email", user.getEmail()) + .claim("nickname", user.getNickname()) + .claim("type", user.getType().name()) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // Refresh Token 생성 + public String generateRefreshToken(User user) { + Date now = new Date(); + Date validity = new Date(now.getTime() + refreshTokenValidity); + + return Jwts.builder() + .setSubject(user.getUserId().toString()) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // JWT 검증 + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + + return !claims.getBody().getExpiration().before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + log.error("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + // User ID 추출 + public Long getUserId(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + return Long.parseLong(claims.getSubject()); + } + + // 만료 시간 추출 + public Date getExpirationDate(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getExpiration(); + } +} diff --git a/src/main/java/ita/growin/domain/auth/entity/RefreshToken.java b/src/main/java/ita/growin/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..5d83f19 --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/entity/RefreshToken.java @@ -0,0 +1,36 @@ +package ita.growin.domain.auth.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens") +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 500) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/ita/growin/domain/auth/kakao/KakaoApiClient.java b/src/main/java/ita/growin/domain/auth/kakao/KakaoApiClient.java new file mode 100644 index 0000000..4ee520d --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/kakao/KakaoApiClient.java @@ -0,0 +1,131 @@ +package ita.growin.domain.auth.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import ita.growin.domain.auth.dto.response.KakaoAuthToken; +import ita.growin.domain.auth.dto.response.KakaoTokenResponse; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class KakaoApiClient { + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + private final RestTemplate restTemplate; + private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String KAKAO_AUTH_CODE_URL ="https://kauth.kakao.com/oauth/authorize"; + + public KakaoApiClient() { + this.restTemplate = new RestTemplate(); + } + + public KakaoUserInfo getUserInfo(String code) { + try { +// ResponseEntity authorizeCode =requestAuthorizeCode(code); + KakaoTokenResponse tokenResponse = requestKakaoAccessToken(code); + ResponseEntity kakaoUserInfo = requestUserInfo(tokenResponse.getAccess_token()); + + return kakaoUserInfo.getBody(); + + } catch (Exception e) { + log.error("카카오 API 호출 실패", e); + throw new RuntimeException("카카오 사용자 정보 조회 실패", e); + } + } + + private ResponseEntity requestUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URL, + HttpMethod.GET, + entity, + KakaoUserInfo.class + ); + + return response; + + } + + private KakaoTokenResponse requestKakaoAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("client_id", clientId); + form.add("redirect_uri", redirectUri); + form.add("code", code); + HttpEntity> req = new HttpEntity<>(form, headers); + + ResponseEntity res = restTemplate.postForEntity( + KAKAO_TOKEN_URL, req, KakaoTokenResponse.class); + + if (!res.getStatusCode().is2xxSuccessful() || res.getBody() == null) { + throw new IllegalStateException("카카오 토큰 발급 실패: " + res.getStatusCode()); + } + return res.getBody(); + } + + private ResponseEntity requestAuthorizeCode(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("client_id", clientId); + form.add("redirect_uri", redirectUri); + form.add("code", code); + + HttpEntity> req = new HttpEntity<>(form, headers); + return restTemplate.postForEntity(KAKAO_AUTH_CODE_URL, req, KakaoAuthToken.class); + } + + @Getter + @Setter + public static class KakaoUserInfo { + private Long id; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + @Setter + public static class KakaoAccount { + private String email; + + @JsonProperty("email_needs_agreement") + private Boolean emailNeedsAgreement; + + private Profile profile; + + @Getter + @Setter + public static class Profile { + private String nickname; + } + } + } +} diff --git a/src/main/java/ita/growin/domain/auth/kakao/KakaoUserInfo.java b/src/main/java/ita/growin/domain/auth/kakao/KakaoUserInfo.java new file mode 100644 index 0000000..edc915e --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/kakao/KakaoUserInfo.java @@ -0,0 +1,29 @@ +package ita.growin.domain.auth.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class KakaoUserInfo { + private Long id; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + public static class KakaoAccount { + private String email; + + @JsonProperty("email_needs_agreement") + private Boolean emailNeedsAgreement; + + private Profile profile; + + @Getter + @Setter + public static class Profile { + private String nickname; + } + } +} diff --git a/src/main/java/ita/growin/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/ita/growin/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..0101015 --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package ita.growin.domain.auth.repository; + +import ita.growin.domain.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByUserId(Long userId); + void deleteByToken(String token); +} diff --git a/src/main/java/ita/growin/domain/auth/security/JwtAuthenticationFilter.java b/src/main/java/ita/growin/domain/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..bc6b78b --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package ita.growin.domain.auth.security; + +import ita.growin.domain.auth.entity.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +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.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserId(token); + + // Request에 userId 추가 + request.setAttribute("userId", userId); + + // Security Context에 인증 정보 저장 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("JWT 인증 성공 - User ID: {}", userId); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/ita/growin/domain/auth/security/SecurityConfig.java b/src/main/java/ita/growin/domain/auth/security/SecurityConfig.java new file mode 100644 index 0000000..68103bf --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/security/SecurityConfig.java @@ -0,0 +1,43 @@ +package ita.growin.domain.auth.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 비활성화 (최신 문법) + .csrf(AbstractHttpConfigurer::disable) + + // 세션 관리 설정 (최신 문법) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 요청 인증 설정 (최신 문법) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/oauth2/callback/kakao").permitAll() + .anyRequest().authenticated() + ) + + // JWT 필터 추가 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/ita/growin/domain/auth/service/AuthService.java b/src/main/java/ita/growin/domain/auth/service/AuthService.java new file mode 100644 index 0000000..e913f1d --- /dev/null +++ b/src/main/java/ita/growin/domain/auth/service/AuthService.java @@ -0,0 +1,167 @@ +package ita.growin.domain.auth.service; + +import ita.growin.domain.auth.dto.request.KakaoLoginRequest; +import ita.growin.domain.auth.dto.request.KakaoSignupRequest; +import ita.growin.domain.auth.dto.request.RefreshTokenRequest; +import ita.growin.domain.auth.dto.response.AuthResponse; +import ita.growin.domain.auth.dto.response.UserDto; +import ita.growin.domain.auth.entity.JwtTokenProvider; +import ita.growin.domain.auth.entity.RefreshToken; +import ita.growin.domain.auth.kakao.KakaoApiClient; +import ita.growin.domain.auth.kakao.KakaoApiClient.KakaoUserInfo; +import ita.growin.domain.auth.repository.RefreshTokenRepository; +import ita.growin.domain.user.constant.LoginType; +import ita.growin.domain.user.constant.UserStatus; +import ita.growin.domain.user.entity.User; +import ita.growin.domain.user.repository.UserRepository; +import ita.growin.global.util.NicknameGenerator; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.hibernate.usertype.UserType; + +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final KakaoApiClient kakaoApiClient; + private final JwtTokenProvider jwtTokenProvider; + private final NicknameGenerator nicknameGenerator; + + @Transactional + public AuthResponse kakaoSignup(KakaoSignupRequest request) { + // 카카오 API로 유저 정보 조회 + KakaoUserInfo kakaoUser = kakaoApiClient.getUserInfo(request.getCode()); + + // 이메일 중복 체크 + if (userRepository.findByEmail(kakaoUser.getKakaoAccount().getEmail()).isPresent()) { + throw new RuntimeException("이미 가입된 이메일입니다."); + } + + // User 엔티티 생성 및 저장 + User user = User.builder() + .email(kakaoUser.getKakaoAccount().getEmail()) + .nickname(request.getNickname()) + .location(request.getLocation()) + .type(LoginType.KAKAO) + .phone(request.getPhone()) + .status(UserStatus.ACTIVE) + .build(); + + userRepository.save(user); + + // JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(user); + String refreshToken = jwtTokenProvider.generateRefreshToken(user); + + // Refresh Token 저장 + saveRefreshToken(user.getUserId(), refreshToken); + + // 응답 생성 + UserDto userDto = UserDto.from(user); + + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(3600L) + .user(userDto) + .build(); + } + + @Transactional + public AuthResponse kakaoLogin(KakaoLoginRequest request) { + // 1. 카카오 API로 유저 정보 조회 + KakaoApiClient.KakaoUserInfo kakaoUser = kakaoApiClient.getUserInfo(request.getAccessToken()); + + // 2. DB에서 유저 찾기 + User user = userRepository.findByEmail(kakaoUser.getKakaoAccount().getEmail()) + .orElseThrow(() -> new RuntimeException("가입되지 않은 사용자입니다.")); + + // 3. 탈퇴한 사용자 체크 + if (user.getStatus() == UserStatus.WITHDRAW) { + throw new RuntimeException("탈퇴한 사용자입니다."); + } + + // 4. JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(user); + String refreshToken = jwtTokenProvider.generateRefreshToken(user); + + // 5. 기존 Refresh Token 삭제 후 새로 저장 + refreshTokenRepository.deleteByUserId(user.getUserId()); + saveRefreshToken(user.getUserId(), refreshToken); + + log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail()); + + // 6. 응답 생성 + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(3600L) + .user(UserDto.from(user)) + .build(); + } + + @Transactional + public AuthResponse refreshToken(RefreshTokenRequest request) { + String refreshTokenValue = request.getRefreshToken(); + + // 1. Refresh Token 검증 + if (!jwtTokenProvider.validateToken(refreshTokenValue)) { + throw new RuntimeException("유효하지 않은 Refresh Token입니다."); + } + + // 2. DB에서 Refresh Token 확인 + RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenValue) + .orElseThrow(() -> new RuntimeException("등록되지 않은 Refresh Token입니다.")); + + // 3. User 조회 + User user = userRepository.findById(refreshToken.getUserId()) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다.")); + + // 4. 새 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(user); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(user); + + // 5. 기존 Refresh Token 삭제 및 새 토큰 저장 + refreshTokenRepository.deleteByToken(refreshTokenValue); + saveRefreshToken(user.getUserId(), newRefreshToken); + + log.info("토큰 갱신 성공 - User ID: {}", user.getUserId()); + + return AuthResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .tokenType("Bearer") + .expiresIn(3600L) + .user(UserDto.from(user)) + .build(); + } + + @Transactional + public void logout(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + log.info("로그아웃 - User ID: {}", userId); + } + + private void saveRefreshToken(Long userId, String token) { + LocalDateTime expiresAt = LocalDateTime.now().plusDays(7); + + RefreshToken refreshToken = RefreshToken.builder() + .userId(userId) + .token(token) + .expiresAt(expiresAt) + .build(); + + refreshTokenRepository.save(refreshToken); + } +} \ No newline at end of file diff --git a/src/main/java/ita/growin/domain/user/constant/InterestField.java b/src/main/java/ita/growin/domain/user/constant/InterestField.java new file mode 100644 index 0000000..544098c --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/InterestField.java @@ -0,0 +1,5 @@ +package ita.growin.domain.user.constant; + +public enum InterestField { + ECONOMY_MANAGEMENT, IT, HUMAN_EDUCATION_PSYCHOLOGY, DESIGN_ART_MEDIA, NATURE_ENVIRONMENT, NONE +} diff --git a/src/main/java/ita/growin/domain/user/constant/Location.java b/src/main/java/ita/growin/domain/user/constant/Location.java new file mode 100644 index 0000000..12c3d43 --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/Location.java @@ -0,0 +1,4 @@ +package ita.growin.domain.user.constant; + +public enum Location { +} diff --git a/src/main/java/ita/growin/domain/user/constant/LoginType.java b/src/main/java/ita/growin/domain/user/constant/LoginType.java new file mode 100644 index 0000000..7a3a235 --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/LoginType.java @@ -0,0 +1,5 @@ +package ita.growin.domain.user.constant; + +public enum LoginType { + KAKAO, GOOGLE +} diff --git a/src/main/java/ita/growin/domain/user/constant/Target.java b/src/main/java/ita/growin/domain/user/constant/Target.java new file mode 100644 index 0000000..52b0553 --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/Target.java @@ -0,0 +1,5 @@ +package ita.growin.domain.user.constant; + +public enum Target { + WORK_CAREER, MAJOR_CAREER_EXPLORATION, STARTUP, UNDECIDED +} diff --git a/src/main/java/ita/growin/domain/user/constant/UserStatus.java b/src/main/java/ita/growin/domain/user/constant/UserStatus.java new file mode 100644 index 0000000..1dd6ab7 --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/UserStatus.java @@ -0,0 +1,5 @@ +package ita.growin.domain.user.constant; + +public enum UserStatus { + ACTIVE, INACTIVE, WITHDRAW +} diff --git a/src/main/java/ita/growin/domain/user/constant/Work.java b/src/main/java/ita/growin/domain/user/constant/Work.java new file mode 100644 index 0000000..87afc4f --- /dev/null +++ b/src/main/java/ita/growin/domain/user/constant/Work.java @@ -0,0 +1,5 @@ +package ita.growin.domain.user.constant; + +public enum Work { + MIDDLE_TO_HIGH_STUDENT, GRAGUATE_STUDENT, OFFICE_WORKER, OTHER +} diff --git a/src/main/java/ita/growin/domain/user/repository/UserRepository.java b/src/main/java/ita/growin/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..a3a4b89 --- /dev/null +++ b/src/main/java/ita/growin/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package ita.growin.domain.user.repository; + +import ita.growin.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByNickname(String nickname); +} + diff --git a/src/main/java/ita/growin/global/entity/BaseEntity.java b/src/main/java/ita/growin/global/entity/BaseEntity.java new file mode 100644 index 0000000..f701fa1 --- /dev/null +++ b/src/main/java/ita/growin/global/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package ita.growin.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/ita/growin/global/util/NicknameGenerator.java b/src/main/java/ita/growin/global/util/NicknameGenerator.java new file mode 100644 index 0000000..4a905ef --- /dev/null +++ b/src/main/java/ita/growin/global/util/NicknameGenerator.java @@ -0,0 +1,38 @@ +package ita.growin.global.util; + +import ita.growin.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Random; + +@Component +@RequiredArgsConstructor +public class NicknameGenerator { + + private final UserRepository userRepository; + private final Random random = new Random(); + + public String generate() { + String nickname; + int attempts = 0; + int maxAttempts = 100; + + do { + nickname = generateRandomNickname(); + attempts++; + + if (attempts >= maxAttempts) { + // UUID 기반으로 전환 + nickname = "사용자" + System.currentTimeMillis(); + } + } while (userRepository.existsByNickname(nickname)); + + return nickname; + } + + private String generateRandomNickname() { + int number = random.nextInt(10000); + return String.format("사용자%04d", number); + } +} diff --git a/src/main/java/ita/tinybite/TinyBiteApplication.java b/src/main/java/ita/tinybite/TinyBiteApplication.java index 3009578..6f957ed 100644 --- a/src/main/java/ita/tinybite/TinyBiteApplication.java +++ b/src/main/java/ita/tinybite/TinyBiteApplication.java @@ -2,10 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class TinyBiteApplication { - public static void main(String[] args) { SpringApplication.run(TinyBiteApplication.class, args); } diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index f86ccc6..cabdf43 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -1,27 +1,43 @@ package ita.tinybite.domain.user.entity; +import ita.growin.domain.user.constant.LoginType; +import ita.growin.domain.user.constant.UserStatus; +import ita.growin.global.entity.BaseEntity; -import ita.tinybite.domain.event.entity.Event; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; +import lombok.*; +import org.hibernate.annotations.Comment; @Entity +@Table(name = "users") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder @AllArgsConstructor -public class User { +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") - private Long id; + @GeneratedValue + @Comment("uid") + private Long userId; + + @Column(nullable = false, length = 50) + private String email; + + @Column(length = 50) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LoginType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserStatus status; + + @Column(nullable = false, length = 100) + private String nickname; - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List events = new ArrayList<>(); + @Column(nullable = false, length = 100) + private String location; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..21ceded --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,39 @@ +spring: + application: + name: growin + + profiles: + group: + local: "local" + dev: "dev" + test: "test" + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/growin?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: update # 개발: update, 운영: validate 또는 none + properties: + hibernate: + format_sql: true + show_sql: true + dialect: org.hibernate.dialect.MySQLDialect + show-sql: true + + +jwt: + secret: ${JWT_SECRET} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + +logging: + level: + org.hibernate.SQL: debug