Skip to content

Commit c91bc41

Browse files
authored
Merge pull request #407 from Team-0ops/develop
Develop
2 parents 958a5a4 + 4f91ecf commit c91bc41

59 files changed

Lines changed: 1577 additions & 685 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎build.gradle‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ repositories {
2323
mavenCentral()
2424
}
2525

26+
2627
dependencies {
2728
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2829
implementation 'org.springframework.boot:spring-boot-starter-web'
@@ -55,11 +56,15 @@ dependencies {
5556
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
5657

5758
implementation 'org.springframework.boot:spring-boot-starter-webflux'
58-
59-
60-
59+
implementation 'org.springframework.boot:spring-boot-starter-security'
60+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
61+
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // 유저 저장 시
62+
implementation 'com.fasterxml.jackson.core:jackson-databind'
63+
compileOnly 'org.projectlombok:lombok'
64+
annotationProcessor 'org.projectlombok:lombok'
6165
}
6266

67+
6368
tasks.named('test') {
6469
useJUnitPlatform()
6570
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package Oops.backend.common.security.token;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
@ConfigurationProperties(prefix = "app.auth.token-cookie")
8+
class TokenCookieProps {
9+
private String accessCookieName = "AccessToken";
10+
private String refreshCookieName = "RefreshToken";
11+
private String sameSite = "None";
12+
private boolean secure = true;
13+
private String path = "/";
14+
15+
private long accessMaxAgeSec = 30 * 60; // 30분
16+
private long refreshMaxAgeSec = 14 * 24 * 60 * 60; // 14일
17+
18+
19+
public String getAccessCookieName() { return accessCookieName; }
20+
public void setAccessCookieName(String v) { this.accessCookieName = v; }
21+
public String getRefreshCookieName() { return refreshCookieName; }
22+
public void setRefreshCookieName(String v) { this.refreshCookieName = v; }
23+
public String getSameSite() { return sameSite; }
24+
public void setSameSite(String v) { this.sameSite = v; }
25+
public boolean isSecure() { return secure; }
26+
public void setSecure(boolean v) { this.secure = v; }
27+
public String getPath() { return path; }
28+
public void setPath(String v) { this.path = v; }
29+
public long getAccessMaxAgeSec() { return accessMaxAgeSec; }
30+
public void setAccessMaxAgeSec(long v) { this.accessMaxAgeSec = v; }
31+
public long getRefreshMaxAgeSec() { return refreshMaxAgeSec; }
32+
public void setRefreshMaxAgeSec(long v) { this.refreshMaxAgeSec = v; }
33+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package Oops.backend.common.security.token;
2+
3+
4+
import Oops.backend.common.security.util.JwtTokenProvider;
5+
import Oops.backend.domain.auth.dto.response.TokenResponseDto;
6+
import Oops.backend.domain.auth.entity.RefreshToken;
7+
import Oops.backend.domain.auth.repository.RefreshTokenRepository;
8+
import Oops.backend.domain.user.entity.User;
9+
import jakarta.servlet.http.HttpServletResponse;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.http.HttpHeaders;
13+
import org.springframework.http.ResponseCookie;
14+
import org.springframework.stereotype.Service;
15+
16+
@Slf4j
17+
@Service
18+
@RequiredArgsConstructor
19+
public class TokenService {
20+
private final JwtTokenProvider jwtTokenProvider;
21+
private final RefreshTokenRepository refreshTokenRepository;
22+
private final TokenCookieProps tokenCookieProps;
23+
24+
public TokenResponseDto issue(User user) {
25+
String access = jwtTokenProvider.generateAccessToken(user.getId());
26+
String refresh = jwtTokenProvider.generateRefreshToken(user.getId());
27+
28+
RefreshToken rt = refreshTokenRepository.findByUserId(user.getId())
29+
.orElseGet(() -> RefreshToken.of(user.getId(), refresh));
30+
rt.setToken(refresh);
31+
refreshTokenRepository.save(rt);
32+
33+
return TokenResponseDto.of(access, refresh);
34+
}
35+
36+
public void setLoginCookies(HttpServletResponse res, TokenResponseDto tokens) {
37+
ResponseCookie access = ResponseCookie.from(tokenCookieProps.getAccessCookieName(), tokens.getAccessToken())
38+
.httpOnly(true)
39+
.secure(tokenCookieProps.isSecure())
40+
.sameSite(tokenCookieProps.getSameSite())
41+
.path(tokenCookieProps.getPath())
42+
.maxAge(tokenCookieProps.getAccessMaxAgeSec())
43+
.build();
44+
45+
ResponseCookie refresh = ResponseCookie.from(tokenCookieProps.getRefreshCookieName(), tokens.getRefreshToken())
46+
.httpOnly(true)
47+
.secure(tokenCookieProps.isSecure())
48+
.sameSite(tokenCookieProps.getSameSite())
49+
.path(tokenCookieProps.getPath())
50+
.maxAge(tokenCookieProps.getRefreshMaxAgeSec())
51+
.build();
52+
53+
res.addHeader(HttpHeaders.SET_COOKIE, access.toString());
54+
res.addHeader(HttpHeaders.SET_COOKIE, refresh.toString());
55+
}
56+
57+
public void revokeAllAndClearCookies(Long userId, HttpServletResponse res) {
58+
refreshTokenRepository.deleteByUserId(userId);
59+
// 만료시켜서 브라우저 쿠키 제거
60+
ResponseCookie expiredAccess = ResponseCookie.from(tokenCookieProps.getAccessCookieName(), "")
61+
.httpOnly(true).secure(tokenCookieProps.isSecure())
62+
.sameSite(tokenCookieProps.getSameSite())
63+
.path(tokenCookieProps.getPath())
64+
.maxAge(0)
65+
.build();
66+
67+
ResponseCookie expiredRefresh = ResponseCookie.from(tokenCookieProps.getRefreshCookieName(), "")
68+
.httpOnly(true).secure(tokenCookieProps.isSecure())
69+
.sameSite(tokenCookieProps.getSameSite())
70+
.path(tokenCookieProps.getPath())
71+
.maxAge(0)
72+
.build();
73+
74+
res.addHeader(HttpHeaders.SET_COOKIE, expiredAccess.toString());
75+
res.addHeader(HttpHeaders.SET_COOKIE, expiredRefresh.toString());
76+
}
77+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package Oops.backend.common.security.util;
2+
3+
4+
import Oops.backend.domain.auth.AuthenticationContext;
5+
import Oops.backend.domain.user.entity.User;
6+
import Oops.backend.domain.user.repository.UserRepository;
7+
import jakarta.servlet.FilterChain;
8+
import jakarta.servlet.ServletException;
9+
import jakarta.servlet.http.Cookie;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import jakarta.servlet.http.HttpServletResponse;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.http.HttpHeaders;
15+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
16+
import org.springframework.security.core.context.SecurityContextHolder;
17+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
18+
import org.springframework.stereotype.Component;
19+
import org.springframework.util.AntPathMatcher;
20+
import org.springframework.web.filter.OncePerRequestFilter;
21+
22+
import java.io.IOException;
23+
import java.util.Collections;
24+
25+
@Component
26+
@RequiredArgsConstructor
27+
@Slf4j
28+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
29+
30+
private final AuthenticationContext authenticationContext;
31+
private final UserRepository userRepository;
32+
private final JwtTokenProvider jwtTokenProvider;
33+
34+
// 화이트리스트: 인증 없이 통과시킬 경로들
35+
private static final AntPathMatcher matcher = new AntPathMatcher();
36+
private static final String[] WHITELIST = {
37+
"/auth/kakao/callback",
38+
"/auth/naver/callback",
39+
"/api/login/**",
40+
"api/auth/join",
41+
"/oauth2/**",
42+
"/public/**",
43+
"/favicon.ico",
44+
"/error",
45+
"/actuator/**",
46+
"/css/**", "/js/**", "/images/**", "/webjars/**",
47+
"/.well-known/**",
48+
"/json",
49+
"/json/**",
50+
"/devtools/**"
51+
};
52+
53+
@Override
54+
protected boolean shouldNotFilter(HttpServletRequest request) {
55+
String path = request.getRequestURI();
56+
log.info("요청 url: "+path);
57+
for (String p : WHITELIST) {
58+
if (matcher.match(p, path)) return true;
59+
}
60+
return false;
61+
}
62+
63+
@Override
64+
protected void doFilterInternal(
65+
HttpServletRequest request,
66+
HttpServletResponse response,
67+
FilterChain filterChain
68+
) throws ServletException, IOException {
69+
70+
try {
71+
String token = resolveAccessToken(request);
72+
if (token != null && !token.isBlank()) {
73+
if (jwtTokenProvider.validate(token)) {
74+
Long userId = jwtTokenProvider.getUserId(token);
75+
76+
User user = userRepository.findById(userId)
77+
.orElse(null);
78+
if (user != null) {
79+
authenticationContext.setPrincipal(user);
80+
81+
UsernamePasswordAuthenticationToken auth =
82+
new UsernamePasswordAuthenticationToken(
83+
user, null, Collections.emptyList());
84+
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
85+
SecurityContextHolder.getContext().setAuthentication(auth);
86+
}
87+
}
88+
}
89+
} catch (Exception ex) {
90+
SecurityContextHolder.clearContext();
91+
}
92+
93+
filterChain.doFilter(request, response);
94+
}
95+
96+
private String resolveAccessToken(HttpServletRequest request) {
97+
String authz = request.getHeader(HttpHeaders.AUTHORIZATION);
98+
if (authz != null && authz.startsWith("Bearer ")) {
99+
return authz.substring(7);
100+
}
101+
if (request.getCookies() != null) {
102+
for (Cookie c : request.getCookies()) {
103+
if ("AccessToken".equals(c.getName())) {
104+
return c.getValue();
105+
}
106+
}
107+
}
108+
return null;
109+
}
110+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package Oops.backend.common.security.util;
2+
3+
4+
import io.jsonwebtoken.Claims;
5+
import io.jsonwebtoken.JwtException;
6+
import io.jsonwebtoken.Jwts;
7+
import io.jsonwebtoken.SignatureAlgorithm;
8+
import io.jsonwebtoken.io.Decoders;
9+
import io.jsonwebtoken.security.Keys;
10+
import lombok.Getter;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.security.Key;
15+
import java.util.Date;
16+
17+
@Getter
18+
@Component
19+
public class JwtTokenProvider {
20+
21+
private final Key key;
22+
private final long accessMinutes;
23+
private final long refreshDays;
24+
25+
public JwtTokenProvider(
26+
@Value("${jwt.secret-base64}") String base64Secret,
27+
@Value("${jwt.access-minutes}") long accessMinutes,
28+
@Value("${jwt.refresh-days}") long refreshDays
29+
) {
30+
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));
31+
this.accessMinutes = accessMinutes;
32+
this.refreshDays = refreshDays;
33+
}
34+
35+
public String generateAccessToken(Long userId) {
36+
long now = System.currentTimeMillis();
37+
return Jwts.builder()
38+
.setSubject(String.valueOf(userId))
39+
.setIssuedAt(new Date(now))
40+
.setExpiration(new Date(now + accessMinutes * 60_000))
41+
.signWith(key, SignatureAlgorithm.HS256)
42+
.compact();
43+
}
44+
45+
public String generateRefreshToken(Long userId) {
46+
long now = System.currentTimeMillis();
47+
return Jwts.builder()
48+
.setSubject(String.valueOf(userId))
49+
.setIssuedAt(new Date(now))
50+
.setExpiration(new Date(now + refreshDays * 24 * 60 * 60 * 1000))
51+
.signWith(key, SignatureAlgorithm.HS256)
52+
.compact();
53+
}
54+
55+
public boolean validate(String token) {
56+
try {
57+
Jwts.parserBuilder()
58+
.setSigningKey(key)
59+
.build()
60+
.parseClaimsJws(token); // signature, exp 검증
61+
return true;
62+
} catch (JwtException | IllegalArgumentException e) {
63+
return false;
64+
}
65+
}
66+
67+
public Long getUserId(String token) {
68+
Claims claims = Jwts.parserBuilder()
69+
.setSigningKey(key)
70+
.build()
71+
.parseClaimsJws(token)
72+
.getBody();
73+
return Long.valueOf(claims.getSubject());
74+
}
75+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package Oops.backend.common.security.util;
2+
3+
4+
import Oops.backend.common.exception.GeneralException;
5+
import Oops.backend.common.status.ErrorStatus;
6+
import io.jsonwebtoken.*;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Component;
10+
11+
12+
@Component
13+
@Slf4j
14+
@RequiredArgsConstructor
15+
public class JwtUtil {
16+
17+
private final JwtTokenProvider jwtProvider;
18+
19+
public Claims getClaims(String token) {
20+
return Jwts.parserBuilder()
21+
.setSigningKey(jwtProvider.getKey())
22+
.build()
23+
.parseClaimsJws(token)
24+
.getBody();
25+
}
26+
27+
public boolean isTokenValid(String token) {
28+
try {
29+
getClaims(token);
30+
return true;
31+
} catch (SecurityException | MalformedJwtException | io.jsonwebtoken.security.SignatureException e) {
32+
throw new GeneralException(ErrorStatus.INVALID_TOKEN.getMessage());
33+
} catch (ExpiredJwtException e) {
34+
throw new GeneralException(ErrorStatus._UNAUTHORIZED, "토큰 만료");
35+
} catch (UnsupportedJwtException e) {
36+
throw new GeneralException(ErrorStatus.INVALID_TOKEN.getMessage());
37+
} catch (IllegalArgumentException e) {
38+
throw new GeneralException(ErrorStatus.INVALID_TOKEN);
39+
} catch (Exception e) {
40+
log.warn("Unexpected JWT error: {}", e.getMessage());
41+
throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR);
42+
}
43+
}
44+
45+
public Long extractUserIdFromToken(String token) {
46+
try {
47+
Claims claims = getClaims(token);
48+
return Long.valueOf(claims.getSubject());
49+
} catch (Exception e) {
50+
throw new GeneralException(ErrorStatus._NOT_FOUND);
51+
}
52+
}
53+
54+
}

0 commit comments

Comments
 (0)