Skip to content

Commit 0a5bdf1

Browse files
committed
Merge branch 'develop' of https://github.com/TaskFlow-CLAP/TaskFlow-Server into develop
2 parents 87ee143 + 1aa5528 commit 0a5bdf1

File tree

78 files changed

+1981
-111
lines changed

Some content is hidden

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

78 files changed

+1981
-111
lines changed

build.gradle

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,14 @@ dependencies {
5555
// Redis
5656
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
5757

58-
// Spring Security
59-
//implementation 'org.springframework.boot:spring-boot-starter-security'
60-
// testImplementation 'org.springframework.security:spring-security-test'
58+
//Spring Security
59+
implementation 'org.springframework.boot:spring-boot-starter-security'
60+
testImplementation 'org.springframework.security:spring-security-test'
61+
62+
// jwt
63+
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
64+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
65+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
6166

6267
// Mapstruct
6368
implementation "org.projectlombok:lombok"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package clap.server.adapter.inbound.security;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import jakarta.validation.constraints.NotNull;
6+
import org.springframework.security.core.GrantedAuthority;
7+
8+
import java.io.Serial;
9+
import java.io.Serializable;
10+
11+
public class CustomGrantedAuthority implements GrantedAuthority, Serializable {
12+
@Serial
13+
private static final long serialVersionUID = 1L;
14+
15+
private final String role;
16+
17+
@JsonCreator
18+
public CustomGrantedAuthority(
19+
@JsonProperty("authority") @NotNull
20+
String role
21+
) {
22+
this.role = role;
23+
}
24+
25+
@Override
26+
public String getAuthority() {
27+
return role;
28+
}
29+
30+
@Override
31+
public boolean equals(Object obj) {
32+
if (this == obj) {
33+
return true;
34+
}
35+
36+
if (obj instanceof CustomGrantedAuthority cga) {
37+
return this.role.equals(cga.getAuthority());
38+
}
39+
40+
return false;
41+
}
42+
43+
@Override
44+
public int hashCode() {
45+
return this.role.hashCode();
46+
}
47+
48+
@Override
49+
public String toString() {
50+
return this.role;
51+
}
52+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package clap.server.adapter.inbound.security;
2+
3+
import clap.server.adapter.outbound.persistense.entity.member.MemberEntity;
4+
import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus;
5+
import com.fasterxml.jackson.annotation.JsonIgnore;
6+
import lombok.AccessLevel;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
import org.springframework.security.core.GrantedAuthority;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
13+
import java.io.Serial;
14+
import java.util.Collection;
15+
import java.util.List;
16+
17+
@Getter
18+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
19+
public class SecurityUserDetails implements UserDetails {
20+
@Serial
21+
private static final long serialVersionUID = 1L;
22+
23+
private Long userId;
24+
private String username;
25+
private Collection<? extends GrantedAuthority> authorities;
26+
private boolean accountNonLocked;
27+
28+
@JsonIgnore
29+
private boolean enabled;
30+
@JsonIgnore
31+
private String password;
32+
@JsonIgnore
33+
private boolean credentialsNonExpired;
34+
@JsonIgnore
35+
private boolean accountNonExpired;
36+
37+
@Builder
38+
public SecurityUserDetails(
39+
Long userId,
40+
String username,
41+
Collection<? extends GrantedAuthority> authorities,
42+
boolean accountNonLocked
43+
) {
44+
this.userId = userId;
45+
this.username = username;
46+
this.authorities = authorities;
47+
this.accountNonLocked = accountNonLocked;
48+
}
49+
50+
public static UserDetails from(MemberEntity member) {
51+
return SecurityUserDetails.builder()
52+
.userId(member.getMemberId())
53+
.username(member.getName())
54+
.authorities(List.of(new CustomGrantedAuthority(member.getRole().name())))
55+
.accountNonLocked(member.getStatus().equals(MemberStatus.INACTIVE))
56+
.build();
57+
}
58+
59+
@Override
60+
public Collection<? extends GrantedAuthority> getAuthorities() {
61+
return authorities;
62+
}
63+
64+
@Override
65+
public String getPassword() {
66+
return null;
67+
}
68+
69+
@Override
70+
public String getUsername() {
71+
return username;
72+
}
73+
74+
@Override
75+
public boolean isAccountNonExpired() {
76+
throw new UnsupportedOperationException();
77+
}
78+
79+
@Override
80+
public boolean isAccountNonLocked() {
81+
return accountNonLocked;
82+
}
83+
84+
@Override
85+
public boolean isCredentialsNonExpired() {
86+
throw new UnsupportedOperationException();
87+
}
88+
89+
@Override
90+
public boolean isEnabled() {
91+
throw new UnsupportedOperationException();
92+
}
93+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package clap.server.adapter.inbound.security;
2+
3+
import clap.server.adapter.outbound.persistense.repository.member.MemberRepository;
4+
import clap.server.exception.AuthException;
5+
import clap.server.exception.code.MemberErrorCode;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.security.core.userdetails.UserDetails;
8+
import org.springframework.security.core.userdetails.UserDetailsService;
9+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
10+
import org.springframework.stereotype.Service;
11+
12+
@Service
13+
@RequiredArgsConstructor
14+
public class SecurityUserDetailsService implements UserDetailsService {
15+
private final MemberRepository loadMemberPort;
16+
17+
@Override
18+
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
19+
return loadMemberPort.findById(Long.parseLong(username))
20+
.map(SecurityUserDetails::from)
21+
.orElseThrow(() -> new AuthException(MemberErrorCode.MEMBER_NOT_FOUND));
22+
}
23+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package clap.server.adapter.inbound.security.filter;
2+
3+
import clap.server.adapter.outbound.jwt.JwtClaims;
4+
import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys;
5+
import clap.server.application.port.outbound.auth.JwtProvider;
6+
import clap.server.exception.JwtException;
7+
import clap.server.exception.code.AuthErrorCode;
8+
import io.jsonwebtoken.Claims;
9+
import jakarta.servlet.FilterChain;
10+
import jakarta.servlet.ServletException;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import jakarta.validation.constraints.NotNull;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.http.HttpHeaders;
17+
import org.springframework.security.access.AccessDeniedException;
18+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
19+
import org.springframework.security.core.context.SecurityContextHolder;
20+
import org.springframework.security.core.userdetails.UserDetails;
21+
import org.springframework.security.core.userdetails.UserDetailsService;
22+
import org.springframework.security.web.access.AccessDeniedHandler;
23+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
24+
import org.springframework.stereotype.Component;
25+
import org.springframework.util.StringUtils;
26+
import org.springframework.web.filter.OncePerRequestFilter;
27+
28+
import java.io.IOException;
29+
30+
// 요청에서 JWT 토큰을 추출하고 유효성을 검사합니다.
31+
@Slf4j
32+
@Component
33+
@RequiredArgsConstructor
34+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
35+
private static final String TEMPORARY_TOKEN_ALLOWED_ENDPOINT = "/api/members/initial-password";
36+
private final UserDetailsService securityUserDetailsService;
37+
private final JwtProvider accessTokenProvider;
38+
private final JwtProvider temporaryTokenProvider;
39+
private final AccessDeniedHandler accessDeniedHandler;
40+
41+
@Override
42+
protected void doFilterInternal(
43+
@NotNull HttpServletRequest request,
44+
@NotNull HttpServletResponse response,
45+
@NotNull FilterChain filterChain
46+
) throws ServletException, IOException {
47+
try {
48+
if (isAnonymousRequest(request)) {
49+
filterChain.doFilter(request, response);
50+
return;
51+
}
52+
53+
String accessToken = resolveAccessToken(request);
54+
55+
UserDetails userDetails = getUserDetails(accessToken);
56+
authenticateUser(userDetails, request);
57+
} catch (AccessDeniedException e) {
58+
accessDeniedHandler.handle(request, response, e);
59+
return;
60+
}
61+
filterChain.doFilter(request, response);
62+
}
63+
64+
private boolean isAnonymousRequest(HttpServletRequest request) {
65+
String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION);
66+
return accessToken == null;
67+
}
68+
69+
private String resolveAccessToken(
70+
HttpServletRequest request
71+
) throws ServletException {
72+
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
73+
String token = accessTokenProvider.resolveToken(authHeader);
74+
75+
if (!StringUtils.hasText(token)) {
76+
log.error("EMPTY_ACCESS_TOKEN");
77+
handleAuthException(AuthErrorCode.EMPTY_ACCESS_KEY);
78+
}
79+
80+
String requestUrl = request.getRequestURI();
81+
boolean isTemporaryToken = isTemporaryToken(token);
82+
JwtProvider tokenProvider = isTemporaryToken ? temporaryTokenProvider : accessTokenProvider;
83+
84+
log.info("Token is Temporary {}", isTemporaryToken);
85+
86+
if (isTemporaryTokenAllowed(requestUrl) != isTemporaryToken) {
87+
log.error("FORBIDDEN_TEMPORARY_TOKEN_ACCESS");
88+
handleAuthException(AuthErrorCode.FORBIDDEN_ACCESS_TOKEN);
89+
}
90+
91+
// TODO: 블랙리스트 토큰 처리 로직 추가 필요
92+
93+
if (tokenProvider.isTokenExpired(token)) {
94+
log.error("EXPIRED_TOKEN");
95+
handleAuthException(AuthErrorCode.EXPIRED_TOKEN);
96+
}
97+
98+
return token;
99+
}
100+
101+
102+
private boolean isTemporaryTokenAllowed(String requestUrl) {
103+
return requestUrl.equals(TEMPORARY_TOKEN_ALLOWED_ENDPOINT);
104+
}
105+
106+
private boolean isTemporaryToken(String token) {
107+
try {
108+
Claims claims = temporaryTokenProvider.getClaimsFromToken(token);
109+
return claims.get("isTemporary", Boolean.class) != null && claims.get("isTemporary", Boolean.class);
110+
} catch (Exception e) {
111+
return false;
112+
}
113+
}
114+
115+
private UserDetails getUserDetails(String accessToken) {
116+
JwtProvider tokenProvider = isTemporaryToken(accessToken) ? temporaryTokenProvider : accessTokenProvider;
117+
JwtClaims claims = tokenProvider.parseJwtClaimsFromToken(accessToken);
118+
String memberId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue());
119+
return securityUserDetailsService.loadUserByUsername(memberId);
120+
}
121+
122+
private void authenticateUser(UserDetails userDetails, HttpServletRequest request) {
123+
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
124+
userDetails, null, userDetails.getAuthorities()
125+
);
126+
127+
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
128+
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
129+
}
130+
131+
private void handleAuthException(AuthErrorCode authErrorCode) throws ServletException {
132+
JwtException exception = new JwtException(authErrorCode);
133+
throw new ServletException(exception);
134+
}
135+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package clap.server.adapter.inbound.security.filter;
2+
import clap.server.exception.JwtException;
3+
import clap.server.exception.code.AuthErrorCode;
4+
import clap.server.exception.code.BaseErrorCode;
5+
import clap.server.exception.code.CommonErrorCode;
6+
import io.jsonwebtoken.ExpiredJwtException;
7+
import io.jsonwebtoken.MalformedJwtException;
8+
import io.jsonwebtoken.UnsupportedJwtException;
9+
import lombok.AccessLevel;
10+
import lombok.NoArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
13+
import java.security.SignatureException;
14+
import java.util.Map;
15+
import java.util.Optional;
16+
17+
@Slf4j
18+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
19+
public class JwtErrorCodeUtil {
20+
private static final Map<Class<? extends Exception>, BaseErrorCode> ERROR_CODE_MAP = Map.of(
21+
ExpiredJwtException.class, AuthErrorCode.EXPIRED_TOKEN,
22+
MalformedJwtException.class, AuthErrorCode.MALFORMED_TOKEN,
23+
SignatureException.class, AuthErrorCode.TAMPERED_TOKEN,
24+
UnsupportedJwtException.class, AuthErrorCode.UNSUPPORTED_JWT_TOKEN
25+
);
26+
27+
public static BaseErrorCode determineErrorCode(Exception exception, BaseErrorCode defaultErrorCode) {
28+
if (exception instanceof JwtException jwtException)
29+
return jwtException.getErrorCode();
30+
31+
Class<? extends Exception> exceptionClass = exception.getClass();
32+
return ERROR_CODE_MAP.getOrDefault(exceptionClass, defaultErrorCode);
33+
}
34+
35+
36+
public static JwtException determineAuthErrorException(Exception exception) {
37+
return findAuthErrorException(exception).orElseGet(
38+
() -> {
39+
BaseErrorCode errorCode = determineErrorCode(exception, CommonErrorCode.INTERNAL_SERVER_ERROR);
40+
log.debug(exception.getMessage(), exception);
41+
return new JwtException(errorCode);
42+
}
43+
);
44+
}
45+
46+
private static Optional<JwtException> findAuthErrorException(Exception exception) {
47+
if (exception instanceof JwtException) {
48+
return Optional.of((JwtException)exception);
49+
} else if (exception.getCause() instanceof JwtException) {
50+
return Optional.of((JwtException)exception.getCause());
51+
}
52+
return Optional.empty();
53+
}
54+
}

0 commit comments

Comments
 (0)