Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
32 changes: 21 additions & 11 deletions blog_manage/build.gradle
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
id 'org.cyclonedx.bom' version '2.3.0'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.leets.backend'
Expand All @@ -20,20 +19,31 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// Spring Boot 기본
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

runtimeOnly 'com.h2database:h2'
// DB
runtimeOnly 'com.h2database:h2' // 테스트용
runtimeOnly 'mysql:mysql-connector-java:8.0.33' // 실제 배포용

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// 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'

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'mysql:mysql-connector-java:8.0.33'
// OpenAPI (Swagger UI)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'

// 설정 메타데이터
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

// 테스트
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@ public static <T> ApiResponse<T> onFailure(HttpStatus status, String message, T
return response;
}

// Getter 추가 (JSON 직렬화용)
public int getStatus() {
return status;
// 데이터가 없을 때 간단하게 사용
public static <T> ApiResponse<T> onSuccess(String message) {
return onSuccess(HttpStatus.OK, message, null);
}

public String getMessage() {
return message;
public static <T> ApiResponse<T> onFailure(HttpStatus status, String message) {
return onFailure(status, message, null);
}

public T getData() {
return data;
}
// Getter
public int getStatus() { return status; }
public String getMessage() { return message; }
public T getData() { return data; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.leets.backend.blog.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import com.leets.backend.blog.service.CustomUserDetailsService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.io.IOException;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;

public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, CustomUserDetailsService userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
String token = null;
if (header != null && header.startsWith("Bearer ")) {
token = header.substring(7);
}
if (token != null && jwtTokenProvider.validateToken(token)) {
String email = jwtTokenProvider.getSubject(token);
var userDetails = userDetailsService.loadUserByUsername(email);
var auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.leets.backend.blog.config;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.http.*;
import java.io.IOException;

public class JwtEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"Unauthorized\"}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.leets.backend.blog.config;

import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public class JwtTokenProvider {

private final Key key;
private final long accessTokenValidityMs;
private final long refreshTokenValidityMs;

public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long accessValidity,
@Value("${jwt.refresh-token-validity}") long refreshValidity) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenValidityMs = accessValidity;
this.refreshTokenValidityMs = refreshValidity;
}

public String createAccessToken(String subject, String role) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessTokenValidityMs);

return Jwts.builder()
.setSubject(subject)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

public String createRefreshToken(String subject) {
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshTokenValidityMs);

return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

public String generateToken(String subject) {
// 기본적으로 USER 권한으로 AccessToken 생성
return createAccessToken(subject, "ROLE_USER");
}

// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 validationToken 메서드에서 예외 발생 시 false만 반환하고 있는데, 어떤 이유로 토큰 검증에 실패했는지 로그에 남기면 디버깅에 도움이 된다고 합니다!

} catch (JwtException | IllegalArgumentException ex) {
    log.warn("Invalid JWT token: {}", ex.getMessage()); 
    return false;
}

이런식으로 하는 게 좋다고 하네요 저도 몰랐는데 알아갑니당 👀👀

}
}

// 토큰에서 이메일 추출
public String getSubject(String token) {
return Jwts.parser().setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}

// 토큰 만료시간 추출
public Date getExpiration(String token) {
return Jwts.parser().setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
}
}
Original file line number Diff line number Diff line change
@@ -1,49 +1,74 @@
package com.leets.backend.blog.config;

import com.leets.backend.blog.service.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;

// 생성자를 통한 의존성 주입
public SecurityConfig(JwtTokenProvider jwtTokenProvider, CustomUserDetailsService userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}

// 비밀번호 인코더 Bean 등록
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 세션 비활성화
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// AuthenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 (API 서버에서는 보통 비활성화)
.cors(cors -> cors.disable())
// JWT 인증 필터 Bean 등록
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService);
}

// 폼 로그인, http 인증 비활성화
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
// 보안 필터 체인 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화
.csrf(csrf -> csrf.disable())

// 요청에 대한 인가(Authorization) 설정
.authorizeHttpRequests(authorize -> authorize
// Swagger UI 및 API 문서 경로에 대한 접근 허용
// 요청별 인가 규칙 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**", // 인증 관련 경로 허용
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/swagger-resources/**",
"/webjars/**",
"/api/**"
"/swagger-ui.html"
).permitAll()
.requestMatchers(
"/posts/**",
"/"
).permitAll()
// 그 외 모든 요청은 인가 필요
.anyRequest().authenticated()
);
.anyRequest().authenticated() // 나머지는 인증 필요
)

// 세션 사용 안 함 (STATELESS)
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.leets.backend.blog.controller;

import com.leets.backend.blog.dto.auth.*;
import com.leets.backend.blog.common.ApiResponse;
import com.leets.backend.blog.service.AuthService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}

// 회원가입
@PostMapping("/signup")
public ResponseEntity<ApiResponse<Void>> signup(@RequestBody SignUpRequest req) {
authService.signup(req);
return ResponseEntity.ok(ApiResponse.onSuccess("User registered"));
}

// 로그인
@PostMapping("/login")
public ResponseEntity<ApiResponse<TokenResponse>> login(@RequestBody LoginRequest req) {
TokenResponse tokens = authService.login(req);
return ResponseEntity.ok(ApiResponse.onSuccess(HttpStatus.OK, "Login success", tokens));
}

// 토큰 재발급
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenResponse>> refresh(@RequestBody RefreshTokenRequest rreq) {
TokenResponse tokens = authService.refreshToken(rreq.getRefreshToken());
return ResponseEntity.ok(ApiResponse.onSuccess(HttpStatus.OK, "Token refreshed", tokens));
}

// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(@RequestParam String email) {
authService.logout(email);
return ResponseEntity.ok(ApiResponse.onSuccess("Logged out"));
}
}
Comment on lines +41 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 로그아웃을 할 때 URL 쿼리 파라미터로 이메일을 받고 있는데, 이 코드는 현재 로그인한 사용자가 아닌 파라미터로 넘어온 다른 사용자를 로그아웃 시킬 수 있는 보안상의 위험이 있다고 합니다!! 로그아웃은 현재 인증된 사용자를 기준으로 처리해주시면 좋을 것 같아요 👍🏻👍🏻

Loading