Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/

application.properties

14 changes: 14 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/lgcns/Docking/DockingApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ public static void main(String[] args) {
}

}


17 changes: 17 additions & 0 deletions src/main/java/com/lgcns/Docking/security/config/CorsMvcConfig.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
87 changes: 87 additions & 0 deletions src/main/java/com/lgcns/Docking/security/jwt/JWTFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
53 changes: 53 additions & 0 deletions src/main/java/com/lgcns/Docking/security/jwt/JWTUtil.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> 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;
}
}
Loading
Loading