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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// Jwt Token
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// AWS
implementation platform("software.amazon.awssdk:bom:2.25.62")
implementation "software.amazon.awssdk:s3"
Expand Down
68 changes: 68 additions & 0 deletions src/main/java/Mua/Mua_backend/domain/member/entity/Member.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package Mua.Mua_backend.domain.member.entity;

import Mua.Mua_backend.global.security.oauth.AuthProvider;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;

@Column(name = "nickname")
private String nickname;

@Column(name = "email", nullable = false)
private String email;

@Column(name = "refresh_token")
private String refreshToken;

@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role;

@Column(nullable = false)
@Enumerated(EnumType.STRING)
private AuthProvider provider;

@Column(nullable = false)
private String providerId;

public static Member createOAuthUser(
String email,
AuthProvider provider,
String providerId
) {
Member member = new Member();
member.email = email;
member.provider = provider;
member.providerId = providerId;
member.role = Role.USER;
return member;
}

public void updateNickname(String nickname) {
this.nickname = nickname;
}

public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}

public void clearRefreshToken() {
this.refreshToken = null;
}

public boolean isAdmin() {
return this.role == Role.ADMIN;
}
}
5 changes: 5 additions & 0 deletions src/main/java/Mua/Mua_backend/domain/member/entity/Role.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package Mua.Mua_backend.domain.member.entity;

public enum Role {
USER, ADMIN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package Mua.Mua_backend.domain.member.repository;

import Mua.Mua_backend.domain.member.entity.Member;
import Mua.Mua_backend.global.security.oauth.AuthProvider;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByEmail(String email);

Optional<Member> findByProviderAndProviderId(
AuthProvider provider,
String providerId
);
}
18 changes: 17 additions & 1 deletion src/main/java/Mua/Mua_backend/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package Mua.Mua_backend.global.config;

import Mua.Mua_backend.global.security.jwt.JwtTokenFilter;
import Mua.Mua_backend.global.security.jwt.JwtTokenUtil;
import Mua.Mua_backend.global.security.oauth.CustomOAuth2UserService;
import Mua.Mua_backend.global.security.oauth.OAuth2LoginSuccessHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
Expand All @@ -8,6 +12,7 @@
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
Expand All @@ -18,6 +23,10 @@
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtTokenUtil jwtTokenUtil;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
Expand All @@ -38,14 +47,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// OAuth2 소셜로그인 설정
.oauth2Login(oauth -> oauth
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2LoginSuccessHandler) // JWT 발급 리다이렉트 등
)
// 예외 처리 설정
.exceptionHandling(exception -> exception
.authenticationEntryPoint((req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((req, res, ex) -> res.sendError(HttpServletResponse.SC_FORBIDDEN))
)
.headers(headers ->
headers.frameOptions(frame -> frame.disable())
);
)
// 로그인 이후 매 요청마다
.addFilterBefore(new JwtTokenFilter(jwtTokenUtil), UsernamePasswordAuthenticationFilter.class);

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package Mua.Mua_backend.global.security.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
public class RefreshToken {

@Id
private Long memberId;

@Column(nullable = false, length = 500)
private String token;

@Column(nullable = false)
private LocalDateTime expiration;

public RefreshToken(Long memberId) {
this.memberId = memberId;
}

public RefreshToken(Long memberId, String token, LocalDateTime expiration) {
this.memberId = memberId;
this.token = token;
this.expiration = expiration;
}

public void rotate(String newToken, LocalDateTime newExpiration) {
this.token = newToken;
this.expiration = newExpiration;
}

public boolean isExpired() {
return expiration.isBefore(LocalDateTime.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package Mua.Mua_backend.global.security.jwt;

import Mua.Mua_backend.global.security.entity.RefreshToken;
import Mua.Mua_backend.global.security.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;

@RestController
@RequestMapping("/token")
@RequiredArgsConstructor
public class JwtLoginAPIController {

private final JwtTokenUtil jwtTokenUtil;
private final RefreshTokenRepository refreshTokenRepository;

@PostMapping("/refresh")
public ResponseEntity<?> refresh(
@RequestHeader("Refresh-Token") String refreshToken
) {
try {
// DB에서 RefreshToken 조회
RefreshToken storedToken = refreshTokenRepository
.findByToken(refreshToken)
.orElseThrow(() -> new RuntimeException("Refresh token not found"));

// 만료 체크
if (storedToken.isExpired()) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Refresh token expired");
}

Long memberId = storedToken.getMemberId();

// 새 AccessToken 발급 (JWT)
String newAccessToken =
jwtTokenUtil.generateAccessToken(memberId, "USER");

// RefreshToken 회전
String newRefreshToken = java.util.UUID.randomUUID().toString();
storedToken.rotate(
newRefreshToken,
LocalDateTime.now().plusDays(14)
);
refreshTokenRepository.save(storedToken);

// 응답
return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + newAccessToken)
.header("Refresh-Token", newRefreshToken)
.build();

} catch (Exception e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid refresh token");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package Mua.Mua_backend.global.security.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

// OncePerRequestFilter : 매번 들어갈 때마다 체크 해주는 필터
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {

private final JwtTokenUtil jwtTokenUtil;
private static final String TOKEN_COOKIE_NAME = "jwtToken";

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

// 토큰 추출 (헤더 우선, 없으면 쿠키)
String token = resolveToken(request);

// 토큰이 없으면 비로그인 요청 → 그냥 통과
if (token == null) {
filterChain.doFilter(request, response);
return;
}

// JWT 검증 (서명 + 만료)
if (!jwtTokenUtil.validateToken(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired JWT");
return;
}

// JWT에서 사용자 정보 추출
Long memberId = jwtTokenUtil.getMemberId(token);
String role = jwtTokenUtil.getRole(token);

// 인증 객체 생성 (ROLE_ prefix 필수)
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
memberId,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);

SecurityContextHolder.getContext().setAuthentication(authentication);

// 다음 필터로
filterChain.doFilter(request, response);
}

/**
* Authorization 헤더 → 없으면 Cookie(jwtToken)에서 토큰 추출
*/
private String resolveToken(HttpServletRequest request) {

// Authorization Header
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}

// Cookie
if (request.getCookies() == null) {
return null;
}

return Arrays.stream(request.getCookies())
.filter(cookie -> TOKEN_COOKIE_NAME.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
}
Loading