diff --git a/.gitignore b/.gitignore index c2065bc..754f588 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +application.properties + diff --git a/build.gradle b/build.gradle index a2af966..7076860 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,20 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // OAuth2 Client + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Spring Data JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // MySQL Driver + runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + } tasks.named('test') { diff --git a/src/main/java/com/lgcns/Docking/DockingApplication.java b/src/main/java/com/lgcns/Docking/DockingApplication.java index 3521fac..7a65b39 100644 --- a/src/main/java/com/lgcns/Docking/DockingApplication.java +++ b/src/main/java/com/lgcns/Docking/DockingApplication.java @@ -11,3 +11,5 @@ public static void main(String[] args) { } } + + diff --git a/src/main/java/com/lgcns/Docking/security/config/CorsMvcConfig.java b/src/main/java/com/lgcns/Docking/security/config/CorsMvcConfig.java new file mode 100644 index 0000000..6074d2a --- /dev/null +++ b/src/main/java/com/lgcns/Docking/security/config/CorsMvcConfig.java @@ -0,0 +1,17 @@ +package com.lgcns.Docking.security.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + + corsRegistry.addMapping("/**") + .exposedHeaders("Set-Cookie") + .allowedOrigins("http://localhost"); + } +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/security/config/SecurityConfig.java b/src/main/java/com/lgcns/Docking/security/config/SecurityConfig.java new file mode 100644 index 0000000..c827ac7 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/security/config/SecurityConfig.java @@ -0,0 +1,98 @@ +package com.lgcns.Docking.security.config; + +import com.lgcns.Docking.security.jwt.JWTFilter; +import com.lgcns.Docking.security.jwt.JWTUtil; +import com.lgcns.Docking.security.oauth2.CustomSuccessHandler; +import com.lgcns.Docking.user.service.CustomOAuth2UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Collections; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomSuccessHandler customSuccessHandler; + private final JWTUtil jwtUtil; + + public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) { + this.customOAuth2UserService = customOAuth2UserService; + this.customSuccessHandler = customSuccessHandler; + this.jwtUtil = jwtUtil; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Collections.singletonList("http://localhost")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders(Collections.singletonList("Authorization")); + return configuration; + } + })); + + // CSRF 비활성화 + http.csrf(csrf -> csrf.disable()); + + // 경로별 인가 설정 + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/").permitAll() + .requestMatchers("/news/post", "/admin/users/news/","/api/comment/").authenticated() + .requestMatchers("/swagger-ui","/news/**","/api/comment/**","/auth/logout").permitAll() + .anyRequest().authenticated() + ); + + // 로그인 및 인증 방식 비활성화 + http.formLogin(form -> form.disable()); + http.httpBasic(basic -> basic.disable()); + + // JWT 필터 추가 + http.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + // OAuth2 로그인 설정 + http.oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(customSuccessHandler) + ); + + // 예외 처리 설정 + http.exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint((request, response, authException) -> { + // 로그인되지 않은 사용자는 네이버 로그인 페이지로 이동 + response.sendRedirect("oauth2/authorization/kakao"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + // 로그인은 했지만 ADMIN 권한이 없는 경우 403 응답 + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"error\": \"ADMIN 권한이 없습니다.\"}"); + }) + ); + + // 세션 상태 설정 (STATELESS) + http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/src/main/java/com/lgcns/Docking/security/jwt/JWTFilter.java b/src/main/java/com/lgcns/Docking/security/jwt/JWTFilter.java new file mode 100644 index 0000000..ab3efc8 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/security/jwt/JWTFilter.java @@ -0,0 +1,87 @@ +package com.lgcns.Docking.security.jwt; + +import com.lgcns.Docking.user.dto.CustomOAuth2User; +import com.lgcns.Docking.user.dto.UserDTO; + +import jakarta.servlet.*; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +@Slf4j +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + //cookie들을 불러온 뒤 Authorization Key에 담긴 쿠키를 찾음 + + // 헤더 검증 + String authorization = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + + System.out.println(cookie.getName()); + if (cookie.getName().equals("Authorization")) { + + authorization = cookie.getValue(); + } + } + + //Authorization 헤더 검증 + if (authorization == null) { + + System.out.println("token null"); + filterChain.doFilter(request, response); + + //조건이 해당되면 메소드 종료 + return; + } + + //토큰 + String token = authorization; + + //토큰 소멸 시간 검증 + if (jwtUtil.isExpired(token)) { + + System.out.println("token expired"); + filterChain.doFilter(request, response); + + //조건이 해당되면 메소드 종료 (필수) + return; + } + + //토큰에서 username과 role 획득 + String password = jwtUtil.getUsername(token); + String id = jwtUtil.getId(token); + + //userDTO를 생성하여 값 set + UserDTO userDTO = new UserDTO(); + userDTO.setUsername(password); + userDTO.setId(id); + + //UserDetails에 회원 정보 객체 담기 + CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO); + + //스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities()); + + //세션에 사용자 등록 + SecurityContextHolder.getContext().setAuthentication(authToken); + logger.info("jwtFilter success"); + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/security/jwt/JWTUtil.java b/src/main/java/com/lgcns/Docking/security/jwt/JWTUtil.java new file mode 100644 index 0000000..05b43ed --- /dev/null +++ b/src/main/java/com/lgcns/Docking/security/jwt/JWTUtil.java @@ -0,0 +1,53 @@ +package com.lgcns.Docking.security.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}")String secret) { + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); + } + + public String getId(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("id", String.class); + } + + public Boolean isExpired(String token) { + try { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload().getExpiration() + .before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } + } + + + public String createJwt(String username, String role, String id, Long expiredMs) { + + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .claim("id", id) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/security/oauth2/CustomSuccessHandler.java b/src/main/java/com/lgcns/Docking/security/oauth2/CustomSuccessHandler.java new file mode 100644 index 0000000..e29ff88 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/security/oauth2/CustomSuccessHandler.java @@ -0,0 +1,65 @@ +package com.lgcns.Docking.security.oauth2; + +import com.lgcns.Docking.security.jwt.JWTUtil; +import com.lgcns.Docking.user.dto.CustomOAuth2User; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +@Component +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JWTUtil jwtUtil; + + public CustomSuccessHandler(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + //OAuth2User + CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); + + // username + String password = customUserDetails.getPassword(); + + // id + String id = customUserDetails.getId(); + + // role + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // token = username + role + String token = jwtUtil.createJwt(password, role, id, 60 * 60 * 24 * 30L); + + System.out.println(token); + + // 쿠키 방식 전달 + 프론트에 리다이렉트 + response.addCookie(createCookie("Authorization", token)); + response.sendRedirect("http://localhost/"); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 24 * 30); + //cookie.setSecure(true); + cookie.setPath("/"); + cookie.setHttpOnly(false); + + return cookie; + } +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/user/dto/CustomOAuth2User.java b/src/main/java/com/lgcns/Docking/user/dto/CustomOAuth2User.java new file mode 100644 index 0000000..248d986 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/dto/CustomOAuth2User.java @@ -0,0 +1,41 @@ +package com.lgcns.Docking.user.dto; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class CustomOAuth2User implements OAuth2User { + + private final UserDTO userDTO; + + public CustomOAuth2User(UserDTO userDTO) { + this.userDTO = userDTO; + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + return List.of(() -> "ROLE_USER"); // 기본 권한 디폴트 부여 + } + + @Override + public String getName() { + return userDTO.getName(); + } + + public String getPassword() { + return userDTO.getUsername(); + } + + public String getId(){ + return userDTO.getId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/user/dto/KakaoResponse.java b/src/main/java/com/lgcns/Docking/user/dto/KakaoResponse.java new file mode 100644 index 0000000..08cd06a --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/dto/KakaoResponse.java @@ -0,0 +1,37 @@ +package com.lgcns.Docking.user.dto; + +import java.util.Map; + +public class KakaoResponse implements OAuth2Response { + + private final Map attributes; + private final Map kakaoAccount; + private final Map profile; + + @SuppressWarnings("unchecked") + public KakaoResponse(Map attributes) { + this.attributes = attributes; + this.kakaoAccount = (Map) attributes.get("kakao_account"); + this.profile = kakaoAccount != null + ? (Map) kakaoAccount.get("profile") + : null; + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); // 고유 식별자, 항상 제공 + } + + @Override + public String getName() { + if (profile != null && profile.get("nickname") != null) { + return profile.get("nickname").toString(); // 실명은 없으므로 nickname으로 대체 + } + return null; + } +} diff --git a/src/main/java/com/lgcns/Docking/user/dto/OAuth2Response.java b/src/main/java/com/lgcns/Docking/user/dto/OAuth2Response.java new file mode 100644 index 0000000..9e913ce --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/dto/OAuth2Response.java @@ -0,0 +1,9 @@ +package com.lgcns.Docking.user.dto; + +public interface OAuth2Response { + String getProvider(); + //제공자에서 발급해주는 아이디(번호) + String getProviderId(); + //사용자 실명 (설정한 이름) + String getName(); +} diff --git a/src/main/java/com/lgcns/Docking/user/dto/UserDTO.java b/src/main/java/com/lgcns/Docking/user/dto/UserDTO.java new file mode 100644 index 0000000..c5c0936 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/dto/UserDTO.java @@ -0,0 +1,12 @@ +package com.lgcns.Docking.user.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserDTO { + private String name; // 사용자 id + private String username; // 서버에서 사용자 이름 + private String id; +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/user/entity/User.java b/src/main/java/com/lgcns/Docking/user/entity/User.java new file mode 100644 index 0000000..d1a69db --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/entity/User.java @@ -0,0 +1,22 @@ +package com.lgcns.Docking.user.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; // OAuth2에서 제공하는 식별자 + + private String name; + + private String profileImg = "default"; // 프로필 이미지 경로 + + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/user/repository/UserRepository.java b/src/main/java/com/lgcns/Docking/user/repository/UserRepository.java new file mode 100644 index 0000000..9015a51 --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.lgcns.Docking.user.repository; + +import com.lgcns.Docking.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + User findByUsername(String username); +} \ No newline at end of file diff --git a/src/main/java/com/lgcns/Docking/user/service/CustomOAuth2UserService.java b/src/main/java/com/lgcns/Docking/user/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..650dd5a --- /dev/null +++ b/src/main/java/com/lgcns/Docking/user/service/CustomOAuth2UserService.java @@ -0,0 +1,84 @@ +package com.lgcns.Docking.user.service; + +import com.lgcns.Docking.user.dto.CustomOAuth2User; +import com.lgcns.Docking.user.dto.KakaoResponse; +import com.lgcns.Docking.user.dto.OAuth2Response; +import com.lgcns.Docking.user.dto.UserDTO; +import com.lgcns.Docking.user.entity.User; +import com.lgcns.Docking.user.repository.UserRepository; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + public CustomOAuth2UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + System.out.println(oAuth2User); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2Response oAuth2Response = null; // 인터페이스 바구니 생성,, + + if (registrationId.equals("kakao")) { + oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); + } + else { + return null; + } + + // 우리 서버에서 관리할 OAuth2 인증 시 제공하는 사용자의 고유 ID를 반환 + String username = oAuth2Response.getProviderId(); + + User existData = userRepository.findByUsername(username); + + if (existData == null) { + + User userEntity = new User(); + + userEntity.setUsername(username); + userEntity.setName(oAuth2Response.getName()); + + User user = userRepository.save(userEntity); + + UserDTO userDTO = new UserDTO(); + + userDTO.setUsername(user.getUsername()); + userDTO.setName(user.getName()); + userDTO.setId(String.valueOf(user.getId())); + + return new CustomOAuth2User(userDTO); + } + else { + + existData.setName(oAuth2Response.getName()); + + User user = userRepository.save(existData); + + UserDTO userDTO = new UserDTO(); + + userDTO.setUsername(existData.getUsername()); + userDTO.setName(oAuth2Response.getName()); + + /* + userDTO.setUsername(user.getUsername()); + userDTO.setName(user.getName()); + */ + + userDTO.setId(String.valueOf(user.getId())); + + return new CustomOAuth2User(userDTO); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9380fd1..66338f9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,32 @@ spring.application.name=Docking + +spring.security.oauth2.client.registration.kakao.client-name=kakao +spring.security.oauth2.client.registration.kakao.client-id=b1f38a881bfca7dcf23846e0fd1f2597 +spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao +spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.kakao.scope=profile_nickname + +# provider +spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token +spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me +spring.security.oauth2.client.provider.kakao.user-name-attribute=id + +spring.datasource.driver-class -name=com.mysql.cj.jdbc.Driver +spring.datasource.url=jdbc:mysql://localhost:3306/lgcns +spring.datasource.username= root +spring.datasource.password= 022skt342m! + +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect +spring.jpa.properties.hibernate.show_sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.use_sql_comments=true +spring.jpa.hibernate.ddl-auto=create +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl +spring.jpa.properties.hibernate.default_batch_fetch_size=1000 + +spring.jwt.secret=vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb + +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.web=DEBUG +