diff --git a/build.gradle b/build.gradle index 95625a7..05fd25b 100644 --- a/build.gradle +++ b/build.gradle @@ -22,9 +22,20 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + //oauth + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -33,10 +44,10 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' // MYSQL - implementation 'com.mysql:mysql-connector-j:9.1.0' + //implementation 'com.mysql:mysql-connector-j:9.1.0' // H2 - //runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java new file mode 100644 index 0000000..9cc6989 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -0,0 +1,64 @@ +package com.moongeul.backend.api.member.controller; + +import com.moongeul.backend.api.member.dto.LoginResponseDTO; +import com.moongeul.backend.api.member.dto.LoginRequestDTO; +import com.moongeul.backend.api.member.dto.UserInfoDTO; +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.service.MemeberService; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.response.ApiResponse; +import com.moongeul.backend.common.response.ErrorStatus; +import com.moongeul.backend.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Member", description = "Member(회원) 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/member") +public class MemberController { + + private final MemeberService memberService; + + @Operation( + summary = "로그인 API", + description = "구글 인가코드을 통해 사용자의 정보를 등록 및 토큰 + 역할을 발급합니다. (ROLE -> 처음사용자 : GUEST, 일반사용자 : USER, 관리자 : ADMIN)" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "구글 엑세스토큰이 입력되지 않았습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 엑세스토큰 입니다.") + }) + @PostMapping("/login") + public ResponseEntity> loginWithGoogle(@RequestBody LoginRequestDTO loginRequestDTO) { + // 엑세스토큰이 입력되지 않았을 경우 예외 처리 + if (loginRequestDTO == null || loginRequestDTO.getCode() == null || loginRequestDTO.getCode().isEmpty()) { + throw new BadRequestException(ErrorStatus.MISSING_GOOGLE_ACCESSTOKEN.getMessage()); + } + + LoginResponseDTO response = memberService.loginWithGoogle(loginRequestDTO.getCode()); + return ApiResponse.success(SuccessStatus.SEND_LOGIN_SUCCESS, response); + } + + @Operation( + summary = "사용자 정보 조회 API", + description = "토큰을 통해 인증된 사용자의 정보를 반환합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 사용자를 찾을 수 없습니다.") + }) + @GetMapping("/user-info") + public ResponseEntity> getUserInfo(@AuthenticationPrincipal UserDetails userDetails){ + Member member = memberService.getMemberByEmail(userDetails.getUsername()); + UserInfoDTO response = memberService.getUserInfo(member); + return ApiResponse.success(SuccessStatus.GET_USERINFO_SUCCESS, response); + } + +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/LoginRequestDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/LoginRequestDTO.java new file mode 100644 index 0000000..ab70de8 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/LoginRequestDTO.java @@ -0,0 +1,8 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.Getter; + +@Getter +public class LoginRequestDTO { + private String code; // 인가코드 +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/LoginResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/LoginResponseDTO.java new file mode 100644 index 0000000..bf6d9c5 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/LoginResponseDTO.java @@ -0,0 +1,17 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginResponseDTO { + + private String role; + private String accessToken; // JWT Access Token (우리 서버) + private String refreshToken; // JWT Refresh Token (우리 서버) +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java new file mode 100644 index 0000000..409f0d6 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java @@ -0,0 +1,14 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserInfoDTO { + + private final Long id; + private final String name; // 회원 이름(실명) + private final String profileImage; // 회원 이미지 + private final String nickname; //닉네임 (초기랜덤생성) +} diff --git a/src/main/java/com/moongeul/backend/api/member/entity/Member.java b/src/main/java/com/moongeul/backend/api/member/entity/Member.java new file mode 100644 index 0000000..7e36452 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/entity/Member.java @@ -0,0 +1,59 @@ +package com.moongeul.backend.api.member.entity; + +import com.moongeul.backend.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder // 빌더 패턴 사용을 위한 롬복 애너테이션 +@NoArgsConstructor // 기본 생성자 +@AllArgsConstructor // 모든 필드를 포함한 생성자 +@Table(name = "MEMBER") // 데이터베이스 테이블 이름 지정 +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // 회원번호(PK) + + @Column(unique = true, nullable = false) + private String email; // 이메일 + + private String name; // 회원 이름(실명) + private String profileImage; // 회원 이미지 + private String nickname; //닉네임 (초기랜덤생성) + private String password; + + @Column(unique = true) + private String socialId; // 소셜 로그인 ID (고유값) + + private String socialType; // 소셜 로그인 제공자: Google, Kakao ... + + @Enumerated(EnumType.STRING) + private Role role; // 권한, Role.valueOf(role)로 저장 + + private String refreshToken; // Refresh Token + + /** + * 권한 가져오기 + */ + public String getAuthorityKey() { + return this.role.getKey(); + } + + /** + * OAuth2 로그인 시 이름, 사진이 변경될 경우 Entity를 업데이트하는 메서드 + */ + public Member update(String name, String picture) { + this.name = name; + this.profileImage = picture; + return this; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/moongeul/backend/api/member/entity/Role.java b/src/main/java/com/moongeul/backend/api/member/entity/Role.java new file mode 100644 index 0000000..97a604e --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/entity/Role.java @@ -0,0 +1,14 @@ +package com.moongeul.backend.api.member.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + + GUEST("ROLE_GUEST"), USER("ROLE_USER"), ADMIN("ROLE_ADMIN"); + + private final String key; +} + diff --git a/src/main/java/com/moongeul/backend/api/member/jwt/dto/JwtTokenDTO.java b/src/main/java/com/moongeul/backend/api/member/jwt/dto/JwtTokenDTO.java new file mode 100644 index 0000000..cd3a136 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/jwt/dto/JwtTokenDTO.java @@ -0,0 +1,14 @@ +package com.moongeul.backend.api.member.jwt.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +@AllArgsConstructor +public class JwtTokenDTO { + private String grantType; //JWT에 대한 인증 타입 + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/moongeul/backend/api/member/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/moongeul/backend/api/member/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..aef2085 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,51 @@ +package com.moongeul.backend.api.member.jwt.filter; + +import com.moongeul.backend.common.config.jwt.JwtTokenProvider; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.FilterChain; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; +import java.io.IOException; +import jakarta.servlet.ServletException; +import org.springframework.util.StringUtils; + +/* +* 클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터 +* 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리 +* JWT를 통해 username + password 인증을 수행한다는 뜻! +*/ +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends GenericFilterBean { + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + // 1. Request Header에서 JWT 토큰 추출 + String token = resolveToken((HttpServletRequest) request); + + // 2. validateToken으로 토큰 유효성 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + chain.doFilter(request, response); + } + + // Request Header에서 토큰 정보 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + log.info("Authorization Header Value: [{}]", bearerToken); // 💡 값 전체 출력 (로그 레벨 info 이상) + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7).trim(); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java b/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java new file mode 100644 index 0000000..360c430 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package com.moongeul.backend.api.member.repository; + +import com.moongeul.backend.api.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findBySocialId(String socialId); +} diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemeberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemeberService.java new file mode 100644 index 0000000..828595b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/service/MemeberService.java @@ -0,0 +1,190 @@ +package com.moongeul.backend.api.member.service; + +import com.moongeul.backend.api.member.dto.UserInfoDTO; +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.entity.Role; +import com.moongeul.backend.api.member.jwt.dto.JwtTokenDTO; +import com.moongeul.backend.api.member.dto.LoginResponseDTO; +import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.common.config.jwt.JwtTokenProvider; +import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.response.ErrorStatus; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MemeberService { + + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RestTemplate restTemplate = new RestTemplate(); // Google 통신 객체 + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String clientId; + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String clientSecret; + @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") + private String redirectUri; + + // 인가코드 받아 JWT로 교환 및 회원가입/로그인 처리 + @Transactional + public LoginResponseDTO loginWithGoogle(String code){ + + // 1. 인가 코드로 Google Access Token 및 사용자 정보 획득 + Map tokenResponse = getGoogleToken(code); + Map userInfo = getGoogleUserInfo(tokenResponse.get("access_token")); + + // 2. 사용자 정보 추출 + String socialId = (String) userInfo.get("sub"); + String name = (String) userInfo.get("name"); + String picture = (String) userInfo.get("picture"); + + // 3. DB 처리 (회원가입 또는 로그인) + Member member = memberRepository.findBySocialId(socialId) + .map(entity -> entity.update(name, picture)) // 이미 있으면 정보 업데이트 + .orElseGet(() -> signUp(socialId, name, picture)); // 없으면 신규 회원가입 + + // 4. 자체 JWT 토큰 생성 및 반환 + JwtTokenDTO jwtToken = jwtTokenProvider.generateToken(member); + member.updateRefreshToken(jwtToken.getRefreshToken()); // 생성된 refreshToken DB 저장 + + return LoginResponseDTO.builder() + .role(member.getAuthorityKey()) + .accessToken(jwtToken.getAccessToken()) + .refreshToken(jwtToken.getRefreshToken()) + .build(); + } + + // 신규 회원가입 처리 로직 (DB 저장) + private Member signUp(String socialId, String name, String picture) { + Member newUser = Member.builder() + .email(UUID.randomUUID() + "@socialUser.com") + .name(name) + .profileImage(picture) + .password("OAuth Password") // 임시 패스워드 + .socialId(socialId) // 예시 사용자명 생성 + .socialType("google") + .role(Role.USER) + .build(); + return memberRepository.save(newUser); + } + + // Google 토큰 획득 로직 (생략: RestTemplate을 사용해 POST 요청) + private Map getGoogleToken(String code) { + + String decodedCode; + try { + // 인코딩된 code 값(예: %2F)을 원래 값(/)으로 디코딩합니다. + decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); + } catch (Exception e) { + // 디코딩 실패 시 원본 코드를 사용하거나, 예외를 다시 던집니다. + log.error("URL Decoding Failed, using original code.", e); + decodedCode = code; + } + + // ... (실제 Google OAuth 2.0 /token 엔드포인트 통신 로직 구현 필요) + // 1. HTTP Body에 전송할 파라미터를 담습니다. + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", decodedCode); + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("redirect_uri", redirectUri); + params.add("grant_type", "authorization_code"); // 인가 코드를 토큰으로 교환함을 명시 + + // 2. HTTP Header 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); // x-www-form-urlencoded 형식 지정 + + // 3. HttpEntity (Header + Body) 생성 + HttpEntity> httpEntity = new HttpEntity<>(params, headers); + + // 4. Google 토큰 엔드포인트 URL + String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + + // 5. POST 요청 및 응답 받기 + try { + ResponseEntity responseEntity = restTemplate.exchange( + GOOGLE_TOKEN_URL, + HttpMethod.POST, + httpEntity, + Map.class + ); + + // 6. 응답에서 토큰 정보 추출 + if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) { + // Map으로 형변환하여 반환 + return (Map) responseEntity.getBody(); + } + } catch (HttpClientErrorException.BadRequest e) { // 💡 Google 400 응답을 여기서 잡습니다. + log.error("Google 400 Response Body: {}", e.getResponseBodyAsString()); + // 이 응답 본문에는 "invalid_grant"가 포함되어 있을 것입니다. + throw new RuntimeException("Google OAuth token exchange failed.", e); + } catch (Exception e) { + // ... (기타 네트워크 오류 처리) + throw new RuntimeException("Google OAuth token exchange failed.", e); + } + // 예외 발생 시 빈 맵 반환 (혹은 특정 예외 던지기) + throw new RuntimeException("Failed to get Google Token."); + } + + // Google 사용자 정보 획득 로직 (생략: RestTemplate을 사용해 GET 요청) + private Map getGoogleUserInfo(String googleAccessToken) { + String GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; + + // 1. HTTP Header에 Google Access Token을 담습니다. + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(googleAccessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + // 2. GET 요청 및 응답 받기 + try { + ResponseEntity responseEntity = restTemplate.exchange( + GOOGLE_USERINFO_URL, + HttpMethod.GET, + entity, + Map.class + ); + + if (responseEntity.getStatusCode().is2xxSuccessful() && responseEntity.getBody() != null) { + return (Map) responseEntity.getBody(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to retrieve Google user info.", e); + } + + throw new RuntimeException("Failed to get Google User Info."); + } + + // 사용자 정보 조회 + public UserInfoDTO getUserInfo(Member member){ + + return UserInfoDTO.builder() + .id(member.getId()) + .name(member.getName()) + .profileImage(member.getProfileImage()) + .nickname(member.getNickname()) + .build(); + } + + @Transactional(readOnly = true) + public Member getMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + } +} diff --git a/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java b/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..59daaf4 --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java @@ -0,0 +1,135 @@ +package com.moongeul.backend.common.config.jwt; + +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.jwt.dto.JwtTokenDTO; +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.core.authority.SimpleGrantedAuthority; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.security.core.GrantedAuthority; + +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +/* +* JwtTokenProvider는 로그인 성공 시 사용자에게 액세스/리프레시 토큰을 발급하는 역할을 하며, +* 이후 모든 요청에서 이 토큰의 유효성(진짜?가짜?)을 검증하고 토큰 정보를 Spring Security 인증 객체로 변환하여 권한을 부여 +*/ + +@Slf4j +@Component +public class JwtTokenProvider { + private final Key key; + + // application.yml에서 secret 값 가져와서 key에 저장 + // 1. 비밀 열쇠 보관 (생성자) : 토큰(신분증)을 만들고 검사할 때 쓰는 비밀 열쇠 + public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + @Value("${jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드 + // 2. 신분증 발급! + public JwtTokenDTO generateToken(Member member) { + + // Member 객체에서 필요한 정보(권한, 이메일/이름)를 직접 추출합니다. + String authorities = member.getAuthorityKey(); // Member Entity에 getAuthority()가 있다고 가정 + String subject = member.getEmail(); // 토큰의 subject는 Member의 이메일로 설정 + + long now = (new Date()).getTime(); + + // Access Token 생성 + Date accessTokenExpiresIn = new Date(now + accessTokenExpirationPeriod); + String accessToken = Jwts.builder() + .setSubject(subject) // 이메일 + .claim("auth", authorities) // 권한 + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + refreshTokenExpirationPeriod)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return JwtTokenDTO.builder() + .grantType("Bearer") //JWT에 대한 인증 타입 + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // 토큰 정보를 검증하는 메서드 + // 3. 신분증 검사 : 제출하는 액세스 토큰이 진짜인지 확인 + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (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; + } + + // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + // 4. 정보 추출 및 권한 부여 + public Authentication getAuthentication(String accessToken) { + // Jwt 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + throw new RuntimeException("권한 정보가 없는 토큰입니다."); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = Arrays.stream(claims.get("auth").toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserDetails 객체를 만들어서 Authentication return + // UserDetails: interface, User: UserDetails를 구현한 class + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + + // accessToken + private Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .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/moongeul/backend/common/config/oauth2/PrincipalDetails.java b/src/main/java/com/moongeul/backend/common/config/oauth2/PrincipalDetails.java new file mode 100644 index 0000000..f841ab9 --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/config/oauth2/PrincipalDetails.java @@ -0,0 +1,99 @@ +package com.moongeul.backend.common.config.oauth2; + +import com.moongeul.backend.api.member.entity.Member; +import lombok.AllArgsConstructor; +import lombok.Getter; +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.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/* +* PrincipalDetails는 데이터베이스의 Member Entity 정보를 담고, +* Spring Security 경비 시스템이 인증과 권한 부여를 처리할 수 있도록 규격화된 명함 역할을 하는 핵심 보안 객체 +*/ + +@Getter +@AllArgsConstructor +@RequiredArgsConstructor +public class PrincipalDetails implements UserDetails, OAuth2User { + + private Member member; + private String username; + private Map attributes; + + //일반 로그인 + public PrincipalDetails(Member member) { + this.member = member; + } + + //OAuth 로그인 + public PrincipalDetails(Member member, Map attributes) { + this.member = member; + } + + @Override + public A getAttribute(String name) { + return OAuth2User.super.getAttribute(name); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getAuthorityKey())); + } + + public Optional getMember() { + // member 필드가 null이 될 수 있으므로, ofNullable을 사용하여 Optional로 감싸서 반환합니다. + return Optional.ofNullable(this.member); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public String getEmail() { + return member.getEmail(); + } + + @Override + public String getName() { + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java b/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java new file mode 100644 index 0000000..f91b0f3 --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java @@ -0,0 +1,56 @@ +package com.moongeul.backend.common.config.oauth2; + +import com.moongeul.backend.api.member.jwt.filter.JwtAuthenticationFilter; +import com.moongeul.backend.common.config.jwt.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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/* +* Spring Boot 애플리케이션의 보안 설정을 담당하는 핵심 클래스 SecurityConfig +* JWT 기반의 무상태(Stateless) 인증 시스템을 구축하고, 애플리케이션의 접근 권한 규칙(인가)를 정의함 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + //private final PrincipalOauth2UserService principalOauth2UserService; + //private final AuthenticationSuccessHandler OAuth2LoginSuccessHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + // 기존 보안 설정... (예: CSRF 비활성화, 인가 설정) + http + .csrf(csrf -> csrf.disable()) // 예시 + .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())) //h2-console 화면 깨짐 방지(iframe 렌더링 오류) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/api/v2/member/login", "/h2-console/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/api-doc/**", "/swagger-ui/**").permitAll() + .anyRequest().authenticated() + ); + + // JWT 필터 추가: 요청 전에 토큰을 검사하도록 설정 + // 모든 요청이 컨트롤러에 도달하기 전에 JWT 토큰을 확인 + http.addFilterBefore( + new JwtAuthenticationFilter(jwtTokenProvider), // JwtTokenProvider 주입 + UsernamePasswordAuthenticationFilter.class // UsernamePasswordAuthenticationFilter 이전에 실행 + ); + +// // OAuth2 로그인 설정 +// http +// .oauth2Login((oauth2) -> oauth2 +// .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint +// .userService(principalOauth2UserService)) // 후처리 로직 연결 +// .successHandler(OAuth2LoginSuccessHandler) +// ); + + return http.build(); + } +} diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index c1fe2ab..9f61a82 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -14,6 +14,7 @@ public enum ErrorStatus { */ VALIDATION_REQUEST_MISSING_EXCEPTION(HttpStatus.BAD_REQUEST, "요청 값이 입력되지 않았습니다."), USER_ALREADY_EXISTS_EXCEPTION(HttpStatus.BAD_REQUEST,"이미 존재하는 사용자입니다."), + MISSING_GOOGLE_ACCESSTOKEN(HttpStatus.BAD_REQUEST, "구글 엑세스토큰이 입력되지 않았습니다."), /** * 401 UNAUTHORIZED diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 0733a2f..83b47d1 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -13,6 +13,8 @@ public enum SuccessStatus { * 200 */ SEND_HEALTH_CHECK_SUCCESS(HttpStatus.OK,"서버 상태 체크 성공"), + SEND_LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공"), + GET_USERINFO_SUCCESS(HttpStatus.OK, "사용자 정보 조회 성공") /** * 201