diff --git a/MR_config/local/application.example.yml b/MR_config/local/application.example.yml index 18d2b032..71dfa30d 100644 --- a/MR_config/local/application.example.yml +++ b/MR_config/local/application.example.yml @@ -24,8 +24,9 @@ spring: # 3. Spring Security 및 JWT 인증 설정 jwt: - # [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요. - # 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes" + # [입력] Base64로 인코딩된 256비트(32바이트) 이상의 Secret Key를 채워주세요. + # (주의: Decoders.BASE64.decode를 사용하므로 Plain Text가 아닌 Base64 인코딩 문자열이어야 합니다.) + # 기입 예시: "c29tZS1zZWNyZXQta2V5LW11c3QtYmUtYXQtbGVhc3QtMzItYmF5dGVzLWxvbmc=" secret: "" access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..ebbc8639 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -0,0 +1,33 @@ +package com.mr.domain.auth.controller; + +import com.mr.domain.auth.dto.AuthRequestDTO; +import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.service.AuthService; +import com.mr.global.apipayload.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PathVariable; +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 +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@Profile({"local", "dev"}) +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login/{socialType}") + public ApiResponse socialLogin( + @PathVariable(name = "socialType") SocialType socialType, + @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request + ) { + AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken()); + return ApiResponse.onSuccess(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java new file mode 100644 index 00000000..57a5ad49 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java @@ -0,0 +1,16 @@ +package com.mr.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public class AuthRequestDTO { + + public record SocialLoginRequest( + @NotBlank(message = "소셜 액세스 토큰은 필수 입력값입니다.") + String accessToken + ) {} + + public record TokenRefreshRequest( + @NotBlank(message = "Refresh Token은 필수 입력값입니다.") + String refreshToken + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java new file mode 100644 index 00000000..dc542d7d --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java @@ -0,0 +1,21 @@ +package com.mr.domain.auth.dto; + +import lombok.Builder; + +public class AuthResponseDTO { + + @Builder + public record TokenResponse( + String accessToken, + String refreshToken, + Long accessTokenExpiresInSeconds + ) {} + + @Builder + public record LoginResponse( + Long userId, + String nickname, + boolean isNewUser, + TokenResponse tokenInfo + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index ba8b38a6..db7a8d65 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -14,7 +14,7 @@ @Getter @Entity -// TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가 +// TODO: 추후 User 도메인 완성 시 인덱스 추가 @Table( name = "social_auth", uniqueConstraints = { @@ -29,7 +29,7 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_auth_id") private Long id; - // TODO: User 엔티티 연관관계 연결 예정 + // TODO: User연결 예정 @Column(name = "user_id", nullable = false) private Long userId; @@ -55,7 +55,7 @@ public class SocialAuth extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { - // 컴파일 에러 수정: 실제 정의된 validateUserAccount 메서드로 매핑 + validateUserAccount(userId); validateUserAccount(socialType); validateUserAccount(socialId); @@ -116,7 +116,6 @@ public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDat this.deviceInfo = deviceInfo; } - // 최신 토큰 만료 및 폐기 처리 public void expireToken() { this.refreshToken = null; this.refreshTokenHash = null; diff --git a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java index bb236510..668ecdd2 100644 --- a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java +++ b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java @@ -1,6 +1,6 @@ package com.mr.domain.auth.entity.enums; public enum SocialType { - KAKAO, - GOOGLE + kakao, + google } diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java new file mode 100644 index 00000000..4ccac698 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -0,0 +1,47 @@ +package com.mr.domain.auth.service; + +import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + // private final KakaoOAuthService kakaoOAuthService; (외부 API 파싱용 서비스) + // private final GoogleOAuthService googleOAuthService; + + @Transactional + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken) { + // 1. 외부 소셜 API (카카오/구글) 통신하여 유저 프로필(email, socialId) 파싱 + // SocialUserInfo userInfo = getSocialUserInfo(socialType, accessToken); + + // 2. TODO: User 엔티티 연동 및 가입여부 검증 (Stub 구조) + // 만약 가입 안 되어있으면 DB User 생성 -> 저장 + Long mockUserId = 1L; + String mockEmail = "user@example.com"; + String mockNickname = "뮤즈유저"; + boolean isNewUser = false; + + String appAccessToken = jwtTokenProvider.createAccessToken(mockUserId); + String appRefreshToken = jwtTokenProvider.createRefreshToken(mockUserId); + + + AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() + .accessToken(appAccessToken) + .refreshToken(appRefreshToken) + .accessTokenExpiresInSeconds(3600L) + .build(); + return AuthResponseDTO.LoginResponse.builder() + .userId(mockUserId) + .nickname(mockNickname) + .isNewUser(isNewUser) + .tokenInfo(tokenResponse) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/entity/enums/UserRole.java b/src/main/java/com/mr/domain/user/entity/enums/UserRole.java new file mode 100644 index 00000000..4d7b9c4c --- /dev/null +++ b/src/main/java/com/mr/domain/user/entity/enums/UserRole.java @@ -0,0 +1,15 @@ +package com.mr.domain.user.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserRole { + ROLE_STUDENT("ROLE_STUDENT", "학생"), + ROLE_TEACHER("ROLE_TEACHER", "강사"), + ROLE_ADMIN("ROLE_ADMIN", "관리자"); + + private final String key; + private final String title; +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/apipayload/code/CommonStatus.java b/src/main/java/com/mr/global/apipayload/code/CommonStatus.java index 25d78bdc..a7869f84 100644 --- a/src/main/java/com/mr/global/apipayload/code/CommonStatus.java +++ b/src/main/java/com/mr/global/apipayload/code/CommonStatus.java @@ -12,7 +12,9 @@ public enum CommonStatus implements BaseCode { SUCCESS(HttpStatus.OK, "COMMON_200", "요청에 성공하였습니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "COMMON_400_01", "입력값이 올바르지 않습니다."), HTTP_MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "COMMON_400_02", "요청 본문(JSON) 파싱에 실패했습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500_01", "서버 에러가 발생했습니다."); + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500_01", "서버 에러가 발생했습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401_01", "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403_01", "금지된 접근입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/global/config/SecurityConfig.java b/src/main/java/com/mr/global/config/SecurityConfig.java deleted file mode 100644 index 7993a7ae..00000000 --- a/src/main/java/com/mr/global/config/SecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.mr.global.config; - -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; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> { - try { - http.csrf(csrfSpec -> csrfSpec.disable()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }) // CSRF 해제 - .authorizeHttpRequests(auth -> auth - // Swagger - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .anyRequest().authenticated() - ); - return http.build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/mr/global/config/SwaggerConfig.java b/src/main/java/com/mr/global/config/SwaggerConfig.java new file mode 100644 index 00000000..4febd52b --- /dev/null +++ b/src/main/java/com/mr/global/config/SwaggerConfig.java @@ -0,0 +1,4 @@ +package com.mr.global.config; + +public class SwaggerConfig { +} diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java new file mode 100644 index 00000000..b28f5968 --- /dev/null +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -0,0 +1,82 @@ +package com.mr.global.security; + +import com.mr.global.security.jwt.JwtAccessDeniedHandler; +import com.mr.global.security.jwt.JwtAuthenticationEntryPoint; +import com.mr.global.security.jwt.JwtAuthenticationFilter; +import com.mr.global.security.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.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + private static final String[] PUBLIC_URLS = { + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/auth/login/**", + "/api/auth/refactor" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(PUBLIC_URLS).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOriginPatterns(List.of( + "http://localhost:3000", + "http://localhost:5173", + "https://*.musereview.site" + )); + + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); + + configuration.setExposedHeaders(List.of("Authorization")); + + configuration.setAllowCredentials(true); + + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/SecurityUtil.java b/src/main/java/com/mr/global/security/SecurityUtil.java new file mode 100644 index 00000000..e19f6ba5 --- /dev/null +++ b/src/main/java/com/mr/global/security/SecurityUtil.java @@ -0,0 +1,30 @@ +package com.mr.global.security; + +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.principal.CustomUserDetails; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + private SecurityUtil() { + } + + public static Long getCurrentUserId() { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new GeneralException(CommonStatus.UNAUTHORIZED); + } + + if (authentication.getPrincipal() instanceof CustomUserDetails userDetails) { + return userDetails.getUserId(); + } + + throw new GeneralException(CommonStatus.UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..26555777 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.mr.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.apipayload.code.CommonStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + // 403 Forbidden 공통 JSON 응답 반환 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + ApiResponse apiResponse = ApiResponse.onFailure( + CommonStatus.FORBIDDEN.getCode(), + CommonStatus.FORBIDDEN.getMessage(), + null + ); + + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..3967a019 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.mr.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.apipayload.code.CommonStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + // 401 Unauthorized 공통 JSON 응답 반환 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse apiResponse = ApiResponse.onFailure( + CommonStatus.UNAUTHORIZED.getCode(), + CommonStatus.UNAUTHORIZED.getMessage(), + null + ); + + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..1375ddcd --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,63 @@ +package com.mr.global.security.jwt; + +import io.jsonwebtoken.JwtException; +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.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String jwt = resolveToken(request); + + if (StringUtils.hasText(jwt)) { + if (!tokenProvider.validateAccessToken(jwt)) { + log.warn("유효하지 않은 JWT 토큰입니다. Token: {}", jwt); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", new JwtException("유효하지 않거나 만료된 토큰입니다.")); + } else { + try { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (UsernameNotFoundException | NumberFormatException e) { + log.error("Security Context에 인증 정보를 저장할 수 없습니다. Token: {}, Error: {}", jwt, e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", e); + } catch (Exception e) { + log.error("JWT 인증 처리 중 알 수 없는 에러 발생: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", e); + } + } + } + + filterChain.doFilter(request, response); + } + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtProperties.java b/src/main/java/com/mr/global/security/jwt/JwtProperties.java new file mode 100644 index 00000000..f4e3b0e8 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtProperties.java @@ -0,0 +1,20 @@ +package com.mr.global.security.jwt; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + @NotBlank(message = "JWT Secret Key는 필수 입력값입니다.") + String secret, + + @Positive(message = "Access Token 만료 시간은 양수여야 합니다.") + long accessTokenValidityInSeconds, + + @Positive(message = "Refresh Token 만료 시간은 양수여야 합니다.") + long refreshTokenValidityInSeconds +) { +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..67423040 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,107 @@ +package com.mr.global.security.jwt; + +import com.mr.global.security.principal.CustomUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(JwtProperties.class) +public class JwtTokenProvider { + + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ACCESS_TYPE = "access"; + private static final String REFRESH_TYPE = "refresh"; + + private final CustomUserDetailsService userDetailsService; + private final JwtProperties jwtProperties; + + private Key key; + + @PostConstruct + protected void init() { + try { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); + this.key = Keys.hmacShaKeyFor(keyBytes); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("[ERROR] JWT Secret Key는 올바른 Base64 인코딩 포맷이어야 합니다.", e); + } + } + + public String createAccessToken(Long userId) { + Claims claims = Jwts.claims().setSubject(String.valueOf(userId)); + claims.put(TOKEN_TYPE_CLAIM, ACCESS_TYPE); + Date now = new Date(); + Date validity = new Date(now.getTime() + jwtProperties.accessTokenValidityInSeconds() * 1000); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Claims claims = Jwts.claims().setSubject(String.valueOf(userId)); + claims.put(TOKEN_TYPE_CLAIM, REFRESH_TYPE); + Date now = new Date(); + Date validity = new Date(now.getTime() + jwtProperties.refreshTokenValidityInSeconds() * 1000); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + String userId = claims.getSubject(); + + UserDetails userDetails = userDetailsService.loadUserByUsername(userId); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + public boolean validateAccessToken(String token) { + return validateTokenWithType(token, ACCESS_TYPE); + } + + public boolean validateRefreshToken(String token) { + return validateTokenWithType(token, REFRESH_TYPE); + } + + private boolean validateTokenWithType(String token, String expectedType) { + try { + Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + String tokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + return expectedType.equals(tokenType); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException e) { + throw new JwtException("만료된 토큰입니다.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/principal/CustomUserDetails.java b/src/main/java/com/mr/global/security/principal/CustomUserDetails.java new file mode 100644 index 00000000..66cc3ea6 --- /dev/null +++ b/src/main/java/com/mr/global/security/principal/CustomUserDetails.java @@ -0,0 +1,54 @@ +package com.mr.global.security.principal; + +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long userId; + private final UserRole role; + + public CustomUserDetails(Long userId, UserRole role) { + if (userId == null || role == null) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + this.userId = userId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.getKey())); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override + public boolean isAccountNonExpired() { return true; } + + @Override + public boolean isAccountNonLocked() { return true; } + + @Override + public boolean isCredentialsNonExpired() { return true; } + + @Override + public boolean isEnabled() { return true; } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java b/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java new file mode 100644 index 00000000..5919b5fc --- /dev/null +++ b/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.mr.global.security.principal; + +import com.mr.domain.user.entity.enums.UserRole; +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; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + // TODO: 추후 User 엔티티 및 UserRepository 완성 시 주입 + // private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + Long userId = Long.parseLong(username); + + // TODO: User user = userRepository.findById(userId) + // .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 사용자입니다. ID: " + userId)); + + return new CustomUserDetails(userId, UserRole.ROLE_STUDENT); + } catch (NumberFormatException e) { + throw new UsernameNotFoundException("올바르지 않은 사용자 ID 포맷입니다: " + username, e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.example.yml b/src/main/resources/application.example.yml new file mode 100644 index 00000000..18d2b032 --- /dev/null +++ b/src/main/resources/application.example.yml @@ -0,0 +1,51 @@ +# ================================================================= # +# 🔒 [NOTICE] 본 파일은 로컬 환경 세팅용 '공통 예시 파일'입니다. +# 각자 로컬 PC 사양에 맞게 정보를 입력한 후, 파일 이름을 +# 'application.yml'로 변경하여 동일한 경로에 위치시켜 주세요! +# ================================================================= # + +spring: + # 1. 로컬 데이터베이스 커넥션 설정 (PostgreSQL 18) + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/mr_db # [체크] 로컬에 mr_db 빈 데이터베이스를 먼저 생성 + username: postgres # [입력] PostgreSQL 계정명 + password: "" # [입력] PostgreSQL 비밀번호 (빈값 입력 가능) + + # 2. JPA 및 하이버네이트 구동 설정 + jpa: + hibernate: + ddl-auto: update # 엔티티 매핑 정보 변경 시 DB 테이블 자동 반영 + show-sql: true # 콘솔에 실행 SQL 포맷 출력 + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + +# 3. Spring Security 및 JWT 인증 설정 +jwt: + # [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요. + # 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes" + secret: "" + access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) + refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) + +# 4. 외부 소셜 로그인 API 연동 정보 (OAuth용) +oauth: + kakao: + client-id: "" # [입력] 카카오 디벨로퍼스 REST API 키 + client-secret: "" # [입력] 카카오 보안 Client Secret 키 + redirect-uri: http://localhost:8080/login/oauth2/code/kakao + google: + client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID + client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 + redirect-uri: http://localhost:8080/login/oauth2/code/google + +# 5. AI 서버 연동 설정 +ai: + internal: + base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 + connect-timeout: 3s + read-timeout: 30s + endpoints: + analyze: /analyze \ No newline at end of file