diff --git a/build.gradle b/build.gradle index 13e40b5..b038d61 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id "java" - id "org.springframework.boot" version "3.5.6" + id "org.springframework.boot" version "3.5.6" // 사용자 버전 유지 id "io.spring.dependency-management" version "1.1.7" } @@ -19,19 +19,26 @@ repositories { } dependencies { - // 기본 웹 라이브러리 + // 1. 기본 웹 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' - // 데이터베이스 연동(JPA) 라이브러리 + // 2. 데이터베이스 연동(JPA) 라이브러리 + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // 테스트용 H2 인메모리 데이터베이스 - runtimeOnly 'com.h2database:h2' - // Lombok 라이브러리 (코드 자동 생성) + + // 3. 보안 및 JWT 관련 라이브러리 (추가됨) + implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 코어 + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT 인터페이스 + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' // JWT 구현체 + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 처리를 위한 Jackson 연동 + testImplementation 'org.springframework.security:spring-security-test' // 보안 테스트용 + + // 4. 유틸리티 및 DB 드라이버 compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' - // --- ▲▲▲▲▲ 여기까지 추가 ▲▲▲▲▲ --- - // 기본 테스트 라이브러리 + // 5. 테스트 라이브러리 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/example/devSns/config/SecurityConfig.java b/src/main/java/com/example/devSns/config/SecurityConfig.java new file mode 100644 index 0000000..1fa1644 --- /dev/null +++ b/src/main/java/com/example/devSns/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.example.devSns.config; + +import com.example.devSns.security.JwtFilter; +import com.example.devSns.security.JwtTokenProvider; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 설정 Disable + .csrf((csrf) -> csrf.disable()) + + // 세션 사용 안 함 (STATELESS) + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 요청 권한 설정 + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/api/auth/**", "/h2-console/**").permitAll() // 로그인, 회원가입은 누구나 접근 가능 + .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 + ) + + // H2 Console iframe 허용 + .headers((headers) -> headers.frameOptions(frameOptions -> frameOptions.disable())) + + // JwtFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록 + .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/controller/AuthController.java b/src/main/java/com/example/devSns/controller/AuthController.java new file mode 100644 index 0000000..aa0ea28 --- /dev/null +++ b/src/main/java/com/example/devSns/controller/AuthController.java @@ -0,0 +1,36 @@ +package com.example.devSns.controller; + +import com.example.devSns.dto.MemberLoginRequestDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.dto.TokenRequestDto; +import com.example.devSns.dto.TokenDto; +import com.example.devSns.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody MemberSignUpRequestDto requestDto) { + return ResponseEntity.ok(authService.signup(requestDto)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody MemberLoginRequestDto requestDto) { + return ResponseEntity.ok(authService.login(requestDto)); + } + + @PostMapping("/reissue") + public ResponseEntity reissue(@RequestBody TokenRequestDto requestDto) { + return ResponseEntity.ok(authService.reissue(requestDto)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Authority.java b/src/main/java/com/example/devSns/domain/Authority.java new file mode 100644 index 0000000..694c963 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Authority.java @@ -0,0 +1,5 @@ +package com.example.devSns.domain; + +public enum Authority { + ROLE_USER, ROLE_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java index 08a379d..bc75e5d 100644 --- a/src/main/java/com/example/devSns/domain/Member.java +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -1,16 +1,21 @@ package com.example.devSns.domain; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - import java.util.ArrayList; import java.util.List; @Entity @Getter @NoArgsConstructor -@Table(name = "members") // 'user'는 H2 DB 등에서 예약어일 수 있으므로 'members' 사용 + +@AllArgsConstructor // Builder 패턴 사용 시 필요 +@Builder // ★ 클래스 레벨에 추가하여 builder() 메서드 자동 생성 ★ +@Table(name = "members") + public class Member { @Id @@ -21,19 +26,25 @@ public class Member { private String username; // 로그인 ID @Column(nullable = false) - private String password; // 실제로는 해싱(Hashing) 필요 + + private String password; @Column(nullable = false, unique = true) - private String nickname; // 사용자가 표시할 이름 + private String nickname; + + @Enumerated(EnumType.STRING) + private Authority authority; // 권한 - // Member가 삭제되면, 관련 Post도 모두 삭제 (Cascade) @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default // Builder 사용 시 초기화 값을 유지하기 위해 필요 private List posts = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default private List comments = new ArrayList<>(); @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Builder.Default private List likes = new ArrayList<>(); public Member(String username, String password, String nickname) { diff --git a/src/main/java/com/example/devSns/domain/RefreshToken.java b/src/main/java/com/example/devSns/domain/RefreshToken.java new file mode 100644 index 0000000..ebf6343 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.example.devSns.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Table(name = "refresh_token") +@Entity +public class RefreshToken { + + @Id + @Column(name = "rt_key") + private String key; // Member ID (username) + + @Column(name = "rt_value") + private String value; // Refresh Token 값 + + @Builder + public RefreshToken(String key, String value) { + this.key = key; + this.value = value; + } + + public RefreshToken updateValue(String token) { + this.value = token; + return this; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java b/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java new file mode 100644 index 0000000..e4d9411 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/MemberLoginRequestDto.java @@ -0,0 +1,19 @@ +package com.example.devSns.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class MemberLoginRequestDto { + private String username; + private String password; + + // 아이디/비번을 인증 토큰 형태로 변환하는 메서드 + public UsernamePasswordAuthenticationToken toAuthentication() { + return new UsernamePasswordAuthenticationToken(username, password); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberResponseDto.java b/src/main/java/com/example/devSns/dto/MemberResponseDto.java index e96af6b..b3e66fc 100644 --- a/src/main/java/com/example/devSns/dto/MemberResponseDto.java +++ b/src/main/java/com/example/devSns/dto/MemberResponseDto.java @@ -1,15 +1,25 @@ package com.example.devSns.dto; import com.example.devSns.domain.Member; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder public class MemberResponseDto { - private Long id; + private String username; private String nickname; - public MemberResponseDto(Member member) { - this.id = member.getId(); - this.nickname = member.getNickname(); + // Member 엔티티를 받아서 DTO로 변환해주는 정적 메서드 + public static MemberResponseDto of(Member member) { + return MemberResponseDto.builder() + .username(member.getUsername()) + .nickname(member.getNickname()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java index f45b00e..7f78fdc 100644 --- a/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java +++ b/src/main/java/com/example/devSns/dto/MemberSignUpRequestDto.java @@ -1,12 +1,29 @@ package com.example.devSns.dto; +import com.example.devSns.domain.Authority; +import com.example.devSns.domain.Member; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.security.crypto.password.PasswordEncoder; @Getter @Setter +@NoArgsConstructor + public class MemberSignUpRequestDto { private String username; private String password; private String nickname; + + // DTO 정보를 바탕으로 Member 엔티티를 만드는 메서드 + public Member toMember(PasswordEncoder passwordEncoder) { + return Member.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .nickname(nickname) + .authority(Authority.ROLE_USER) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/TokenDto.java b/src/main/java/com/example/devSns/dto/TokenDto.java new file mode 100644 index 0000000..e3b4094 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/TokenDto.java @@ -0,0 +1,15 @@ +package com.example.devSns.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class TokenDto { + private String grantType; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/dto/TokenRequestDto.java b/src/main/java/com/example/devSns/dto/TokenRequestDto.java new file mode 100644 index 0000000..8e1bb90 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/TokenRequestDto.java @@ -0,0 +1,11 @@ +package com.example.devSns.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenRequestDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/MemberRepository.java b/src/main/java/com/example/devSns/repository/MemberRepository.java index 2488af8..3e90b3d 100644 --- a/src/main/java/com/example/devSns/repository/MemberRepository.java +++ b/src/main/java/com/example/devSns/repository/MemberRepository.java @@ -8,6 +8,12 @@ @Repository public interface MemberRepository extends JpaRepository { - // 닉네임으로 회원 검색 (검색 기능용) + // 닉네임으로 회원 검색 Optional findByNickname(String nickname); + + // 로그인 ID(username)로 회원 검색 (★ 이 부분이 빠져있어서 에러가 났습니다 ★) + Optional findByUsername(String username); + + // 중복 가입 방지용 존재 여부 확인 (★ 이 부분도 같이 추가해주세요 ★) + boolean existsByUsername(String username); } \ No newline at end of file diff --git a/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java b/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..6b7fd77 --- /dev/null +++ b/src/main/java/com/example/devSns/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByKey(String key); +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/security/JwtFilter.java b/src/main/java/com/example/devSns/security/JwtFilter.java new file mode 100644 index 0000000..efda3d2 --- /dev/null +++ b/src/main/java/com/example/devSns/security/JwtFilter.java @@ -0,0 +1,47 @@ +package com.example.devSns.security; + +import jakarta.servlet.FilterChain; +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.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + // 1. Request Header 에서 토큰을 꺼냄 + String jwt = resolveToken(request); + + // 2. validateToken 으로 토큰 유효성 검사 + // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장 + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + Authentication authentication = jwtTokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + // Request Header 에서 토큰 정보 꺼내오기 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/security/JwtTokenProvider.java b/src/main/java/com/example/devSns/security/JwtTokenProvider.java new file mode 100644 index 0000000..65094d4 --- /dev/null +++ b/src/main/java/com/example/devSns/security/JwtTokenProvider.java @@ -0,0 +1,120 @@ +package com.example.devSns.security; + +import com.example.devSns.dto.TokenDto; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +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.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final Key key; + private final long accessTokenValidityInMilliseconds; + + private static final String AUTHORITIES_KEY = "auth"; + // Refresh Token은 보통 Access Token보다 길게 잡으므로 별도 상수로 두거나 설정으로 뺄 수 있습니다. (여기선 기존 7일 유지) + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; + + // 생성자: @Value에 :기본값 문법을 사용하여 설정 파일 누락 시에도 안전하게 작동하도록 수정 + public JwtTokenProvider( + @Value("${jwt.secret:c2lsdmVyLW5pbmUtZGV2LXNucy1qd3Qtc2VjcmV0LWtleS1leGFtcGxlCg==}") String secretKey, + @Value("${jwt.expiration:1800000}") long accessTokenValidityInMilliseconds) { // 기본값 30분 + + // Base64 디코딩 + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + } + + // 토큰 생성 (Access Token & Refresh Token) + public TokenDto generateToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + + // Access Token 생성 (설정된 만료 시간 사용) + Date accessTokenExpiresIn = new Date(now + this.accessTokenValidityInMilliseconds); + String accessToken = Jwts.builder() + .setSubject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return TokenDto.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiresIn(accessTokenExpiresIn.getTime()) + .build(); + } + + // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null) { + throw new RuntimeException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + // 토큰 정보를 검증하는 메서드 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } + return false; + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/AuthService.java b/src/main/java/com/example/devSns/service/AuthService.java new file mode 100644 index 0000000..400ef73 --- /dev/null +++ b/src/main/java/com/example/devSns/service/AuthService.java @@ -0,0 +1,94 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.RefreshToken; +import com.example.devSns.dto.MemberLoginRequestDto; +import com.example.devSns.dto.MemberResponseDto; +import com.example.devSns.dto.MemberSignUpRequestDto; +import com.example.devSns.dto.TokenRequestDto; +import com.example.devSns.repository.MemberRepository; +import com.example.devSns.repository.RefreshTokenRepository; +import com.example.devSns.security.JwtTokenProvider; +import com.example.devSns.dto.TokenDto; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public MemberResponseDto signup(MemberSignUpRequestDto requestDto) { + if (memberRepository.existsByUsername(requestDto.getUsername())) { + throw new RuntimeException("이미 가입되어 있는 유저입니다"); + } + + Member member = requestDto.toMember(passwordEncoder); + return MemberResponseDto.of(memberRepository.save(member)); + } + + @Transactional + public TokenDto login(MemberLoginRequestDto requestDto) { + // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성 + UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication(); + + // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분 + // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행됨 + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + // 3. 인증 정보를 기반으로 JWT 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateToken(authentication); + + // 4. RefreshToken 저장 + RefreshToken refreshToken = RefreshToken.builder() + .key(authentication.getName()) + .value(tokenDto.getRefreshToken()) + .build(); + + refreshTokenRepository.save(refreshToken); + + // 5. 토큰 발급 + return tokenDto; + } + + @Transactional + public TokenDto reissue(TokenRequestDto tokenRequestDto) { + // 1. Refresh Token 검증 + if (!jwtTokenProvider.validateToken(tokenRequestDto.getRefreshToken())) { + throw new RuntimeException("Refresh Token 이 유효하지 않습니다."); + } + + // 2. Access Token 에서 Member ID 가져오기 + Authentication authentication = jwtTokenProvider.getAuthentication(tokenRequestDto.getAccessToken()); + + // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴 + RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName()) + .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다.")); + + // 4. Refresh Token 일치하는지 검사 + if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) { + throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다."); + } + + // 5. 새로운 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateToken(authentication); + + // 6. 저장소 정보 업데이트 + RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken()); + refreshTokenRepository.save(newRefreshToken); + + // 토큰 발급 + return tokenDto; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/CustomUserDetailsService.java b/src/main/java/com/example/devSns/service/CustomUserDetailsService.java new file mode 100644 index 0000000..c2e3ae4 --- /dev/null +++ b/src/main/java/com/example/devSns/service/CustomUserDetailsService.java @@ -0,0 +1,41 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +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 org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return memberRepository.findByUsername(username) + .map(this::createUserDetails) + .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다.")); + } + + // DB 에 있는 Member 정보 -> UserDetails 객체로 변환 + private UserDetails createUserDetails(Member member) { + GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString()); + + return new User( + String.valueOf(member.getId()), + member.getPassword(), + Collections.singleton(grantedAuthority) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java index 2f1e606..7b898c7 100644 --- a/src/main/java/com/example/devSns/service/MemberService.java +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -39,7 +39,7 @@ public MemberResponseDto signup(MemberSignUpRequestDto requestDto) { // TODO: 패스워드 암호화 Member member = new Member(requestDto.getUsername(), requestDto.getPassword(), requestDto.getNickname()); Member savedMember = memberRepository.save(member); - return new MemberResponseDto(savedMember); + return MemberResponseDto.of(savedMember); } // 회원 ID로 조회 (내부 로직용) @@ -53,7 +53,7 @@ public Member getMemberById(Long id) { public MemberResponseDto searchByNickname(String nickname) { Member member = memberRepository.findByNickname(nickname) .orElseThrow(() -> new IllegalArgumentException("Member not found")); - return new MemberResponseDto(member); + return MemberResponseDto.of(member); } // 회원 프로필 조회 (작성한 글, 댓글, 좋아요한 글) diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index 1f6ed56..7a84a1c 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -24,6 +24,7 @@ public class PostService { public Post createPost(PostRequestDto requestDto) { Member member = memberService.getMemberById(requestDto.getMemberId()); Post post = new Post(requestDto.getTitle(), requestDto.getContent(), member); + return postRepository.save(post); } // 모든 게시글 조회 diff --git a/src/main/resources/appication.yml b/src/main/resources/appication.yml new file mode 100644 index 0000000..82186fa --- /dev/null +++ b/src/main/resources/appication.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + +jwt: + secret: c2lsdmVyLW5pbmUtZGV2LXNucy1qd3Qtc2VjcmV0LWtleS1leGFtcGxlCg== # 사용자 제공 키 유지 + expiration: 86400000 # 1일 (24시간) - 토큰 만료 시간 추가 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index f3f10af..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=devSns