Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d5a0f83
CLAP-84 Feat: 인증 및 권한 부여를 위한 보안 handler 및 filter 구현
joowojr Jan 22, 2025
2728821
CLAP-84 Chore: Spring security 의존성 주석 해제 및 jwt 의존성 추가
joowojr Jan 22, 2025
8f435b0
CLAP-84 Feat: 사용자 정보를 처리하는 SecurityUserDetailsService 및 SecurityUserD…
joowojr Jan 22, 2025
37eae62
CLAP-84 Feat: redis 리프레시 토큰 outbound adapter 레이어 구현
joowojr Jan 22, 2025
fdd0dfb
CLAP-84 Feat: Access & Refresh 토큰 claim 및 provider 구현
joowojr Jan 22, 2025
f52c957
CLAP-84 Feat: 토큰 provider 및 리프레시 토큰 application port 구현
joowojr Jan 22, 2025
117b810
CLAP-84 Chore: jwt 및 redis 리프레시 토큰 도메인 모델 클래스 구현
joowojr Jan 22, 2025
16f9b24
CLAP-84 Chore: jwt exception 및 AuthErrorCode 추가
joowojr Jan 22, 2025
5d447a6
CLAP-84 Chore: accessDeniedException에 대한 예외 처리기 구현
joowojr Jan 22, 2025
f8f5c0f
CLAP-84 Config: Security 관련 Config 구현
joowojr Jan 22, 2025
223e98b
CLAP-84 Feat : AccessTokenProvider 및 RefreshTokenProvider 주입 시 명시하는 애…
joowojr Jan 22, 2025
cf407ee
CLAP-84 Config : jwt 관련 설정을 담은 auth.yml 추가
joowojr Jan 22, 2025
2b4a73f
CLAP-84 Rename : 디렉토리 이동으로 인한 변경사항
joowojr Jan 22, 2025
cf4a349
CLAP-60 Remove : password 삭제
joowojr Jan 22, 2025
806d4c5
CLAP-60 Feat: 로그인 기능 구현
joowojr Jan 22, 2025
8ccafdb
CLAP-84 Feat: 토큰 생성 및 관리를 담당하는 서비스 구현
joowojr Jan 22, 2025
e575fb9
CLAP-84 Feat: auth 관련 상수 클래스 추가
joowojr Jan 22, 2025
12c6f42
CLAP-84 Feat: swagger에 auth 관련 설정 추가
joowojr Jan 22, 2025
2799697
CLAP-60 Feat: 닉네임으로 유저 찾는 메서드 구현
joowojr Jan 22, 2025
87ad39f
CLAP-84 Feat: AuthException과 JwtException 분리
joowojr Jan 22, 2025
2092e3c
CLAP-84 Docs: swagger 명세 추가
joowojr Jan 22, 2025
40412d3
CLAP-57 Feat: Dateutil 추가
joowojr Jan 22, 2025
c7887ad
CLAP-57 Feat: 초기 비밀번호 생성 util 및 관련 설정 추가
joowojr Jan 22, 2025
71253d3
CLAP-57 Refactor: 회원 등록 관련 로직 수정
joowojr Jan 22, 2025
8e50486
CLAP-57 Rename: 디렉토리 이동
joowojr Jan 22, 2025
75d80ed
CLAP-84 Feat: fianl 선언
joowojr Jan 22, 2025
69c295a
CLAP-57 Rename: 패키지 이동
joowojr Jan 23, 2025
4e37298
CLAP-60 Refactor: login response에 프로필 이미지 필드 추가
joowojr Jan 23, 2025
ef92a02
CLAP-61 Feat: 초기 로그인용 임시 액세스 토큰 발급 로직 구현
joowojr Jan 23, 2025
8070612
CLAP-61 Feat: 임시 토큰 허용 url에 대해서만 임시 토큰을 허용하도록 validation 추가
joowojr Jan 23, 2025
1c56fa2
CLAP-61 Feat: 임시 토큰 발급 메서드 구현
joowojr Jan 23, 2025
caddce6
CLAP-61 Config: 임시 토큰 설정 추가
joowojr Jan 23, 2025
8983908
CLAP-61 Feat: 임시 토큰 허용 url에 대해서만 임시 토큰을 허용하도록 validation 추가
joowojr Jan 23, 2025
bf769c6
CLAP-61 Docs: swagger 명세 수정
joowojr Jan 23, 2025
e608fdf
CLAP-61 Feat: 비밀번호 재설정 API 구현
joowojr Jan 23, 2025
e55c728
CLAP-61 Refactor: default class로 수정
joowojr Jan 23, 2025
d7590f3
CLAP-61 Feat: 비밀번호 형식 validation 추가
joowojr Jan 23, 2025
b98eca7
CLAP-61 Feat: 이메일 형식 validation 추가
joowojr Jan 23, 2025
6799789
Merge pull request #67 from TaskFlow-CLAP/CLAP-61
joowojr Jan 23, 2025
a50caa6
Merge branch 'CLAP-84' into CLAP-57
joowojr Jan 23, 2025
a2b7d40
Merge remote-tracking branch 'origin/CLAP-84' into CLAP-84
joowojr Jan 23, 2025
68dc4f5
Merge branch 'CLAP-84' into CLAP-57
joowojr Jan 23, 2025
07fc216
Merge pull request #58 from TaskFlow-CLAP/CLAP-57
joowojr Jan 23, 2025
ee8f871
Merge branch 'develop' into CLAP-84
joowojr Jan 23, 2025
330caa1
Config: test 애플리케이션 환경 설정 추가
joowojr Jan 23, 2025
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
11 changes: 8 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,14 @@ dependencies {
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// Spring Security
//implementation 'org.springframework.boot:spring-boot-starter-security'
// testImplementation 'org.springframework.security:spring-security-test'
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

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

// Mapstruct
implementation "org.projectlombok:lombok"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package clap.server.adapter.inbound.security;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serial;
import java.io.Serializable;

public class CustomGrantedAuthority implements GrantedAuthority, Serializable {
@Serial
private static final long serialVersionUID = 1L;

private final String role;

@JsonCreator
public CustomGrantedAuthority(
@JsonProperty("authority") @NotNull
String role
) {
this.role = role;
}

@Override
public String getAuthority() {
return role;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}

if (obj instanceof CustomGrantedAuthority cga) {
return this.role.equals(cga.getAuthority());
}

return false;
}

@Override
public int hashCode() {
return this.role.hashCode();
}

@Override
public String toString() {
return this.role;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package clap.server.adapter.inbound.security;

import clap.server.adapter.outbound.persistense.entity.member.MemberEntity;
import clap.server.adapter.outbound.persistense.entity.member.constant.MemberStatus;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serial;
import java.util.Collection;
import java.util.List;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SecurityUserDetails implements UserDetails {
@Serial
private static final long serialVersionUID = 1L;

private Long userId;
private String username;
private Collection<? extends GrantedAuthority> authorities;
private boolean accountNonLocked;

@JsonIgnore
private boolean enabled;
@JsonIgnore
private String password;
@JsonIgnore
private boolean credentialsNonExpired;
@JsonIgnore
private boolean accountNonExpired;

@Builder
public SecurityUserDetails(
Long userId,
String username,
Collection<? extends GrantedAuthority> authorities,
boolean accountNonLocked
) {
this.userId = userId;
this.username = username;
this.authorities = authorities;
this.accountNonLocked = accountNonLocked;
}

public static UserDetails from(MemberEntity member) {
return SecurityUserDetails.builder()
.userId(member.getMemberId())
.username(member.getName())
.authorities(List.of(new CustomGrantedAuthority(member.getRole().name())))
.accountNonLocked(member.getStatus().equals(MemberStatus.INACTIVE))
.build();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public String getPassword() {
return null;
}

@Override
public String getUsername() {
return username;
}

@Override
public boolean isAccountNonExpired() {
throw new UnsupportedOperationException();
}

@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}

@Override
public boolean isCredentialsNonExpired() {
throw new UnsupportedOperationException();
}

@Override
public boolean isEnabled() {
throw new UnsupportedOperationException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package clap.server.adapter.inbound.security;

import clap.server.adapter.outbound.persistense.repository.member.MemberRepository;
import clap.server.exception.AuthException;
import clap.server.exception.code.MemberErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SecurityUserDetailsService implements UserDetailsService {
private final MemberRepository loadMemberPort;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return loadMemberPort.findById(Long.parseLong(username))
.map(SecurityUserDetails::from)
.orElseThrow(() -> new AuthException(MemberErrorCode.MEMBER_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package clap.server.adapter.inbound.security.filter;

import clap.server.adapter.outbound.jwt.JwtClaims;
import clap.server.adapter.outbound.jwt.access.AccessTokenClaimKeys;
import clap.server.application.port.outbound.auth.JwtProvider;
import clap.server.exception.JwtException;
import clap.server.exception.code.AuthErrorCode;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// 요청에서 JWT 토큰을 추출하고 유효성을 검사합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String TEMPORARY_TOKEN_ALLOWED_ENDPOINT = "/api/members/initial-password";
private final UserDetailsService securityUserDetailsService;
private final JwtProvider accessTokenProvider;
private final JwtProvider temporaryTokenProvider;
private final AccessDeniedHandler accessDeniedHandler;

@Override
protected void doFilterInternal(
@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain
) throws ServletException, IOException {
try {
if (isAnonymousRequest(request)) {
filterChain.doFilter(request, response);
return;
}

String accessToken = resolveAccessToken(request);

UserDetails userDetails = getUserDetails(accessToken);
authenticateUser(userDetails, request);
} catch (AccessDeniedException e) {
accessDeniedHandler.handle(request, response, e);
return;
}
filterChain.doFilter(request, response);
}

private boolean isAnonymousRequest(HttpServletRequest request) {
String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION);
return accessToken == null;
}

private String resolveAccessToken(
HttpServletRequest request
) throws ServletException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String token = accessTokenProvider.resolveToken(authHeader);

if (!StringUtils.hasText(token)) {
log.error("EMPTY_ACCESS_TOKEN");
handleAuthException(AuthErrorCode.EMPTY_ACCESS_KEY);
}

String requestUrl = request.getRequestURI();
boolean isTemporaryToken = isTemporaryToken(token);
JwtProvider tokenProvider = isTemporaryToken ? temporaryTokenProvider : accessTokenProvider;

log.info("Token is Temporary {}", isTemporaryToken);

if (isTemporaryTokenAllowed(requestUrl) != isTemporaryToken) {
log.error("FORBIDDEN_TEMPORARY_TOKEN_ACCESS");
handleAuthException(AuthErrorCode.FORBIDDEN_ACCESS_TOKEN);
}

// TODO: 블랙리스트 토큰 처리 로직 추가 필요

if (tokenProvider.isTokenExpired(token)) {
log.error("EXPIRED_TOKEN");
handleAuthException(AuthErrorCode.EXPIRED_TOKEN);
}

return token;
}


private boolean isTemporaryTokenAllowed(String requestUrl) {
return requestUrl.equals(TEMPORARY_TOKEN_ALLOWED_ENDPOINT);
}

private boolean isTemporaryToken(String token) {
try {
Claims claims = temporaryTokenProvider.getClaimsFromToken(token);
return claims.get("isTemporary", Boolean.class) != null && claims.get("isTemporary", Boolean.class);
} catch (Exception e) {
return false;
}
}

private UserDetails getUserDetails(String accessToken) {
JwtProvider tokenProvider = isTemporaryToken(accessToken) ? temporaryTokenProvider : accessTokenProvider;
JwtClaims claims = tokenProvider.parseJwtClaimsFromToken(accessToken);
String memberId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue());
return securityUserDetailsService.loadUserByUsername(memberId);
}

private void authenticateUser(UserDetails userDetails, HttpServletRequest request) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);

authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}

private void handleAuthException(AuthErrorCode authErrorCode) throws ServletException {
JwtException exception = new JwtException(authErrorCode);
throw new ServletException(exception);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package clap.server.adapter.inbound.security.filter;
import clap.server.exception.JwtException;
import clap.server.exception.code.AuthErrorCode;
import clap.server.exception.code.BaseErrorCode;
import clap.server.exception.code.CommonErrorCode;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.security.SignatureException;
import java.util.Map;
import java.util.Optional;

@Slf4j
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class JwtErrorCodeUtil {
private static final Map<Class<? extends Exception>, BaseErrorCode> ERROR_CODE_MAP = Map.of(
ExpiredJwtException.class, AuthErrorCode.EXPIRED_TOKEN,
MalformedJwtException.class, AuthErrorCode.MALFORMED_TOKEN,
SignatureException.class, AuthErrorCode.TAMPERED_TOKEN,
UnsupportedJwtException.class, AuthErrorCode.UNSUPPORTED_JWT_TOKEN
);

public static BaseErrorCode determineErrorCode(Exception exception, BaseErrorCode defaultErrorCode) {
if (exception instanceof JwtException jwtException)
return jwtException.getErrorCode();

Class<? extends Exception> exceptionClass = exception.getClass();
return ERROR_CODE_MAP.getOrDefault(exceptionClass, defaultErrorCode);
}


public static JwtException determineAuthErrorException(Exception exception) {
return findAuthErrorException(exception).orElseGet(
() -> {
BaseErrorCode errorCode = determineErrorCode(exception, CommonErrorCode.INTERNAL_SERVER_ERROR);
log.debug(exception.getMessage(), exception);
return new JwtException(errorCode);
}
);
}

private static Optional<JwtException> findAuthErrorException(Exception exception) {
if (exception instanceof JwtException) {
return Optional.of((JwtException)exception);
} else if (exception.getCause() instanceof JwtException) {
return Optional.of((JwtException)exception.getCause());
}
return Optional.empty();
}
}
Loading
Loading