diff --git a/src/main/java/clap/server/adapter/inbound/security/LoginAttemptFilter.java b/src/main/java/clap/server/adapter/inbound/security/LoginAttemptFilter.java new file mode 100644 index 00000000..ab27a20d --- /dev/null +++ b/src/main/java/clap/server/adapter/inbound/security/LoginAttemptFilter.java @@ -0,0 +1,51 @@ +package clap.server.adapter.inbound.security; + +import clap.server.application.service.auth.LoginAttemptService; +import clap.server.exception.AuthException; +import clap.server.exception.code.CommonErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +import static clap.server.common.constants.AuthConstants.SESSION_ID; + + +@RequiredArgsConstructor +@Slf4j +public class LoginAttemptFilter extends OncePerRequestFilter { + + private static final String LOGIN_ENDPOINT = "/api/auths/login"; + private final LoginAttemptService loginAttemptService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String sessionId = request.getHeader(SESSION_ID.getValue().toLowerCase()); + + if (request.getRequestURI().equals(LOGIN_ENDPOINT)) { + if (sessionId == null) { + throw new AuthException(CommonErrorCode.BAD_REQUEST); + } + loginAttemptService.checkAccountIsLocked(sessionId); + } + + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken("user", null, new ArrayList<>()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java index 152c92ff..8ac0f3a6 100644 --- a/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java +++ b/src/main/java/clap/server/adapter/inbound/security/SecurityUserDetails.java @@ -52,7 +52,7 @@ public static UserDetails from(MemberEntity member) { .userId(member.getMemberId()) .username(member.getName()) .authorities(List.of(new CustomGrantedAuthority(member.getRole().name()))) - .accountNonLocked(member.getStatus().equals(MemberStatus.INACTIVE)) + .accountNonLocked(member.getStatus().equals(MemberStatus.ACTIVE)) .build(); } diff --git a/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java b/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java index cc0bb08d..3c1eb17b 100644 --- a/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java +++ b/src/main/java/clap/server/adapter/inbound/web/auth/AuthController.java @@ -6,12 +6,18 @@ import clap.server.common.annotation.architecture.WebAdapter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import static clap.server.common.utils.ClientIpParseUtil.getClientIp; + +@Slf4j @Tag(name = "로그인 / 로그아웃") @WebAdapter @RequiredArgsConstructor @@ -21,8 +27,13 @@ public class AuthController { @Operation(summary = "로그인 API") @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { - return ResponseEntity.ok(authUsecase.login(request.nickname(), request.password())); + public ResponseEntity login(@RequestHeader(name = "sessionId") String sessionId, + @RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + String clientIp = getClientIp(httpRequest); + LoginResponse response = authUsecase.login(request.nickname(), request.password(), sessionId, clientIp); + return ResponseEntity.ok(response); } + } diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogAdapter.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogAdapter.java new file mode 100644 index 00000000..5770818d --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogAdapter.java @@ -0,0 +1,31 @@ +package clap.server.adapter.outbound.infrastructure.redis.log; + +import clap.server.application.port.outbound.auth.CommandLoginLogPort; +import clap.server.application.port.outbound.auth.LoadLoginLogPort; +import clap.server.domain.model.auth.LoginLog; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LoginLogAdapter implements LoadLoginLogPort, CommandLoginLogPort { + private final LoginLogRepository loginLogRepository; + private final LoginLogMapper loginLogMapper; + + public void save(LoginLog loginLog) { + LoginLogEntity loginLogEntity = loginLogMapper.toEntity(loginLog); + loginLogRepository.save(loginLogEntity); + } + + public void deleteById(String sessionId) { + loginLogRepository.deleteById(sessionId); + } + + public Optional findBySessionId(String sessionId) { + return loginLogRepository.findById(sessionId).map(loginLogMapper::toDomain); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogEntity.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogEntity.java new file mode 100644 index 00000000..6993517c --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogEntity.java @@ -0,0 +1,38 @@ +package clap.server.adapter.outbound.infrastructure.redis.log; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.time.LocalDateTime; + +@Getter +@RedisHash("loginLog") +@Builder +@ToString(of = {"sessionId", "clientIp", "attemptNickname", "lastAttemptAt", "attemptCount", "isLocked"}) +@EqualsAndHashCode(of = {"sessionId"}) +public class LoginLogEntity { + @Id + private String sessionId; + + private String clientIp; + + private String attemptNickname; + + @JsonSerialize(using = ToStringSerializer.class) // 직렬화 방식을 설정 + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @Builder.Default + private LocalDateTime lastAttemptAt = LocalDateTime.now(); + + private int attemptCount; + + private boolean isLocked; +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogMapper.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogMapper.java new file mode 100644 index 00000000..020f8342 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogMapper.java @@ -0,0 +1,20 @@ +package clap.server.adapter.outbound.infrastructure.redis.log; + +import clap.server.domain.model.auth.LoginLog; +import org.mapstruct.InheritInverseConfiguration; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface LoginLogMapper { + + @Mapping(target = "isLocked", source = "locked") + LoginLog toDomain(final LoginLogEntity entity); + + @Mapping(target = "isLocked", source = "locked") + LoginLogEntity toEntity(final LoginLog domain); + + default boolean mapLocked(boolean locked) { + return locked; + } +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogRepository.java b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogRepository.java new file mode 100644 index 00000000..095234d1 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/redis/log/LoginLogRepository.java @@ -0,0 +1,6 @@ +package clap.server.adapter.outbound.infrastructure.redis.log; + +import org.springframework.data.repository.CrudRepository; + +public interface LoginLogRepository extends CrudRepository { +} diff --git a/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java b/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java index ab40ab75..482a175e 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/mapper/MemberPersistenceMapper.java @@ -14,6 +14,7 @@ public interface MemberPersistenceMapper { @Mapping(source = "role", target = "memberInfo.role") @Mapping(source = "departmentRole", target = "memberInfo.departmentRole") @Mapping(source = "department", target = "memberInfo.department") + @Mapping(source = "reviewer", target = "memberInfo.isReviewer") @Mapping(source = "admin", target = "admin") Member toDomain(MemberEntity entity); @@ -23,15 +24,12 @@ public interface MemberPersistenceMapper { @Mapping(source = "memberInfo.role", target = "role") @Mapping(source = "memberInfo.departmentRole", target = "departmentRole") @Mapping(source = "memberInfo.department", target = "department") + @Mapping(source = "memberInfo.reviewer", target = "isReviewer") @Mapping(target = "admin", source = "admin") MemberEntity toEntity(Member member); - default boolean mapIsReviewer(Member member) { - return member.getMemberInfo().isReviewer(); - } - - default boolean mapIsReviewer(MemberEntity entity) { - return entity.isReviewer(); + default boolean mapReviewer(boolean reviewer) { + return reviewer; } } diff --git a/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java b/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java index 562a7391..e67e91c7 100644 --- a/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/auth/AuthUsecase.java @@ -3,5 +3,5 @@ import clap.server.adapter.inbound.web.dto.auth.LoginResponse; public interface AuthUsecase { - LoginResponse login(String nickname, String password); + LoginResponse login(String nickname, String password, String sessionId, String clientIp); } diff --git a/src/main/java/clap/server/application/port/outbound/auth/CommandLoginLogPort.java b/src/main/java/clap/server/application/port/outbound/auth/CommandLoginLogPort.java new file mode 100644 index 00000000..fe6547c9 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/auth/CommandLoginLogPort.java @@ -0,0 +1,9 @@ +package clap.server.application.port.outbound.auth; + +import clap.server.domain.model.auth.LoginLog; + +public interface CommandLoginLogPort { + void save(LoginLog loginLog); + + void deleteById(String sessionId); +} diff --git a/src/main/java/clap/server/application/port/outbound/auth/LoadLoginLogPort.java b/src/main/java/clap/server/application/port/outbound/auth/LoadLoginLogPort.java new file mode 100644 index 00000000..0a02f995 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/auth/LoadLoginLogPort.java @@ -0,0 +1,9 @@ +package clap.server.application.port.outbound.auth; + +import clap.server.domain.model.auth.LoginLog; + +import java.util.Optional; + +public interface LoadLoginLogPort { + Optional findBySessionId(String sessionId); +} diff --git a/src/main/java/clap/server/application/service/auth/AuthService.java b/src/main/java/clap/server/application/service/auth/AuthService.java index 7015a047..0162d8cd 100644 --- a/src/main/java/clap/server/application/service/auth/AuthService.java +++ b/src/main/java/clap/server/application/service/auth/AuthService.java @@ -9,12 +9,14 @@ import clap.server.common.annotation.architecture.ApplicationService; import clap.server.domain.model.auth.CustomJwts; import clap.server.domain.model.member.Member; -import clap.server.exception.ApplicationException; -import clap.server.exception.code.MemberErrorCode; +import clap.server.exception.AuthException; +import clap.server.exception.code.AuthErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; +@Slf4j @ApplicationService @RequiredArgsConstructor class AuthService implements AuthUsecase { @@ -22,34 +24,40 @@ class AuthService implements AuthUsecase { private final CommandRefreshTokenPort commandRefreshTokenPort; private final IssueTokenService issueTokenService; private final PasswordEncoder passwordEncoder; + private final LoginAttemptService loginAttemptService; @Override @Transactional - public LoginResponse login(String nickname, String password) { - Member member = loadMemberPort.findByNickname(nickname).orElseThrow( - () -> new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND)); - validatePassword(password, member.getPassword()); + public LoginResponse login(String nickname, String password, String sessionId, String clientIp) { + Member member = getMember(nickname, sessionId, clientIp); + + validatePassword(password, member.getPassword(), sessionId, nickname, clientIp); if (member.getStatus().equals(MemberStatus.APPROVAL_REQUEST)) { String temporaryToken = issueTokenService.issueTemporaryToken(member.getMemberId()); - return AuthResponseMapper.toLoginResponse( - temporaryToken, null, member - ); - } else { - CustomJwts jwtTokens = issueTokenService.issueTokens(member); - commandRefreshTokenPort.save( - issueTokenService.issueRefreshToken(member.getMemberId()) - ); - return AuthResponseMapper.toLoginResponse( - jwtTokens.accessToken(), jwtTokens.refreshToken(), member - ); + return AuthResponseMapper.toLoginResponse(temporaryToken, null, member); } + + CustomJwts jwtTokens = issueTokenService.issueTokens(member); + commandRefreshTokenPort.save(issueTokenService.issueRefreshToken(member.getMemberId())); + loginAttemptService.resetFailedAttempts(sessionId); + return AuthResponseMapper.toLoginResponse(jwtTokens.accessToken(), jwtTokens.refreshToken(), member); } - private void validatePassword(String inputPassword, String encodedPassword) { + private Member getMember(String inputNickname, String sessionId, String clientIp) { + return loadMemberPort.findByNickname(inputNickname).orElseThrow(() -> + { + loginAttemptService.recordFailedAttempt(sessionId, clientIp, inputNickname); + return new AuthException(AuthErrorCode.LOGIN_REQUEST_FAILED); + }); + } + + private void validatePassword(String inputPassword, String encodedPassword, String sessionId, String inputNickname, String clientIp) { if (!passwordEncoder.matches(inputPassword, encodedPassword)) { - throw new ApplicationException(MemberErrorCode.MEMBER_NOT_FOUND); + loginAttemptService.recordFailedAttempt(sessionId, clientIp, inputNickname); + throw new AuthException(AuthErrorCode.LOGIN_REQUEST_FAILED); } } } + diff --git a/src/main/java/clap/server/application/service/auth/LoginAttemptService.java b/src/main/java/clap/server/application/service/auth/LoginAttemptService.java new file mode 100644 index 00000000..50afc6ea --- /dev/null +++ b/src/main/java/clap/server/application/service/auth/LoginAttemptService.java @@ -0,0 +1,61 @@ +package clap.server.application.service.auth; + +import clap.server.application.port.outbound.auth.CommandLoginLogPort; +import clap.server.application.port.outbound.auth.LoadLoginLogPort; +import clap.server.domain.model.auth.LoginLog; +import clap.server.exception.AuthException; +import clap.server.exception.code.AuthErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@RequiredArgsConstructor +@Component +@Slf4j +public class LoginAttemptService { + private final LoadLoginLogPort loadLoginLogPort; + private final CommandLoginLogPort commandLoginLogPort; + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final long LOCK_TIME_DURATION = 30 * 60 * 1000; // 30분 (밀리초) + + public void recordFailedAttempt(String sessionId, String clientIp, String attemptNickname) { + LoginLog loginLog = loadLoginLogPort.findBySessionId(sessionId).orElse(null); + if (loginLog == null) { + loginLog = LoginLog.createLoginLog(sessionId, clientIp, attemptNickname); + } else { + int attemptCount = loginLog.recordFailedAttempt(); + if (attemptCount >= MAX_FAILED_ATTEMPTS) { + loginLog.setLocked(true); + commandLoginLogPort.save(loginLog); + throw new AuthException(AuthErrorCode.ACCOUNT_IS_LOCKED); + } + } + commandLoginLogPort.save(loginLog); + } + + public void checkAccountIsLocked(String sessionId) { + LoginLog loginLog = loadLoginLogPort.findBySessionId(sessionId).orElse(null); + if (loginLog == null) { + return; + } + + if (loginLog.isLocked()) { + LocalDateTime lastAttemptAt = loginLog.getLastAttemptAt(); + LocalDateTime now = LocalDateTime.now(); + + long minutesSinceLastAttempt = ChronoUnit.MINUTES.between(lastAttemptAt, now); + + if (minutesSinceLastAttempt <= LOCK_TIME_DURATION) { + throw new AuthException(AuthErrorCode.ACCOUNT_IS_LOCKED); + } + } + } + + + public void resetFailedAttempts(String sessionId) { + commandLoginLogPort.deleteById(sessionId); + } +} diff --git a/src/main/java/clap/server/application/ResetPasswordService.java b/src/main/java/clap/server/application/service/member/ResetPasswordService.java similarity index 96% rename from src/main/java/clap/server/application/ResetPasswordService.java rename to src/main/java/clap/server/application/service/member/ResetPasswordService.java index 3f46d878..99d967ec 100644 --- a/src/main/java/clap/server/application/ResetPasswordService.java +++ b/src/main/java/clap/server/application/service/member/ResetPasswordService.java @@ -1,4 +1,4 @@ -package clap.server.application; +package clap.server.application.service.member; import clap.server.application.port.inbound.auth.ResetPasswordUsecase; import clap.server.application.port.inbound.domain.MemberService; diff --git a/src/main/java/clap/server/common/constants/AuthConstants.java b/src/main/java/clap/server/common/constants/AuthConstants.java index 1fbcbb3e..c0170ce5 100644 --- a/src/main/java/clap/server/common/constants/AuthConstants.java +++ b/src/main/java/clap/server/common/constants/AuthConstants.java @@ -4,7 +4,7 @@ @Getter public enum AuthConstants { - AUTHORIZATION("Authorization"), TOKEN_PREFIX("Bearer "); + AUTHORIZATION("Authorization"), TOKEN_PREFIX("Bearer "), SESSION_ID("sessionId"); private final String value; diff --git a/src/main/java/clap/server/common/utils/ClientIpParseUtil.java b/src/main/java/clap/server/common/utils/ClientIpParseUtil.java new file mode 100644 index 00000000..c4980e3b --- /dev/null +++ b/src/main/java/clap/server/common/utils/ClientIpParseUtil.java @@ -0,0 +1,53 @@ +package clap.server.common.utils; + +import jakarta.servlet.http.HttpServletRequest; + +public class ClientIpParseUtil { + + private static final String[] headersToCheck = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR" + }; + + private ClientIpParseUtil() { + throw new IllegalStateException("Utility class"); + } + + public static String getClientIp(HttpServletRequest request) { + String ip = null; + + for (String header : headersToCheck) { + ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + // X-Forwarded-For 헤더에서 첫 번째 IP만 추출 + if (ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + break; + } + } + + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + + if (ip != null && ip.matches("^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$")) { + return ip; + } + + return "127.0.0.1"; + } + +} diff --git a/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java index 550539e2..97808e62 100644 --- a/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java +++ b/src/main/java/clap/server/common/utils/InitialPasswordGenerator.java @@ -7,14 +7,13 @@ import java.security.SecureRandom; -@Component @RequiredArgsConstructor public class InitialPasswordGenerator { private final PasswordPolicyProperties properties; - public String generateRandomPassword() { - return generateRandomPassword(properties.getLength()); + private InitialPasswordGenerator() { + throw new IllegalStateException("Utility class"); } public String generateRandomPassword(int length) { diff --git a/src/main/java/clap/server/config/security/SecurityAdapterConfig.java b/src/main/java/clap/server/config/security/SecurityAdapterConfig.java deleted file mode 100644 index 70f14aa7..00000000 --- a/src/main/java/clap/server/config/security/SecurityAdapterConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package clap.server.config.security; - -import clap.server.adapter.inbound.security.filter.JwtAuthenticationFilter; -import clap.server.adapter.inbound.security.filter.JwtExceptionFilter; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.SecurityConfigurerAdapter; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.DefaultSecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@RequiredArgsConstructor -public class SecurityAdapterConfig extends SecurityConfigurerAdapter { - private final DaoAuthenticationProvider daoAuthenticationProvider; - private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final JwtExceptionFilter jwtExceptionFilter; - - @Override - public void configure(HttpSecurity builder) throws Exception { - builder.authenticationProvider(daoAuthenticationProvider); - builder.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - builder.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); - } -} diff --git a/src/main/java/clap/server/config/security/SecurityConfig.java b/src/main/java/clap/server/config/security/SecurityConfig.java index 4ad8b226..acd06625 100644 --- a/src/main/java/clap/server/config/security/SecurityConfig.java +++ b/src/main/java/clap/server/config/security/SecurityConfig.java @@ -1,15 +1,17 @@ package clap.server.config.security; +import clap.server.adapter.inbound.security.LoginAttemptFilter; +import clap.server.adapter.inbound.security.filter.JwtAuthenticationFilter; +import clap.server.adapter.inbound.security.filter.JwtExceptionFilter; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -19,44 +21,48 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; import static clap.server.config.security.WebSecurityUrl.*; + @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final SecurityAdapterConfig securityAdapterConfig; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + private final LoginAttemptFilter loginAttemptFilter; + + private final DaoAuthenticationProvider daoAuthenticationProvider; private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; private final AuthenticationEntryPoint authenticationEntryPoint; @Bean - @Profile({"local", "dev"}) @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainForDev(HttpSecurity http) throws Exception { + public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { return defaultSecurity(http) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ) .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) + .addFilterBefore(loginAttemptFilter, JwtExceptionFilter.class) .authorizeHttpRequests( auth -> defaultAuthorizeHttpRequest(auth) .requestMatchers(SWAGGER_ENDPOINTS).permitAll() + .requestMatchers(LOGIN_ENDPOINT).permitAll() .anyRequest().authenticated() ).build(); } - @Bean - @Profile({"prod"}) - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainForProd(HttpSecurity http) throws Exception { - return defaultSecurity(http) - .cors(cors -> cors.configurationSource(corsConfigurationSource)) - .authorizeHttpRequests(auth -> defaultAuthorizeHttpRequest(auth).anyRequest().authenticated() - ).build(); - } - private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { return http .httpBasic(AbstractHttpConfigurer::disable) @@ -66,12 +72,8 @@ private HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { ) .formLogin(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) - .with(securityAdapterConfig, Customizer.withDefaults()) - .exceptionHandling( - exception -> exception - .accessDeniedHandler(accessDeniedHandler) - .authenticationEntryPoint(authenticationEntryPoint) - ); + .authenticationProvider(daoAuthenticationProvider) + ; } private AbstractRequestMatcherRegistry.AuthorizedUrl> defaultAuthorizeHttpRequest( @@ -83,8 +85,7 @@ private AbstractRequestMatcherRegistry exception(Exception e, WebRequest request) { ); } - @ExceptionHandler(value = { ApplicationException.class, DomainException.class }) + @ExceptionHandler(value = { BaseException.class }) public ResponseEntity onThrowException( BaseException exception, HttpServletRequest request) { diff --git a/src/main/java/clap/server/exception/code/AuthErrorCode.java b/src/main/java/clap/server/exception/code/AuthErrorCode.java index 36a9e9c4..c223d41d 100644 --- a/src/main/java/clap/server/exception/code/AuthErrorCode.java +++ b/src/main/java/clap/server/exception/code/AuthErrorCode.java @@ -21,7 +21,10 @@ public enum AuthErrorCode implements BaseErrorCode { FORBIDDEN_ACCESS_TOKEN(HttpStatus.FORBIDDEN, "AUTH_012","해당 토큰에는 엑세스 권한이 없습니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED,"AUTH_013", "유효하지 않은 토큰입니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH_014", "리프레시 토큰을 찾을 수 없습니다."), - REFRESH_TOKEN_MISMATCHED(HttpStatus.UNAUTHORIZED, "AUTH_014", "리프레시 토큰이 일치하지 않습니다"); + REFRESH_TOKEN_MISMATCHED(HttpStatus.UNAUTHORIZED, "AUTH_014", "리프레시 토큰이 일치하지 않습니다"), + ACCOUNT_IS_LOCKED(HttpStatus.UNAUTHORIZED, "AUTH_015", "접근할 수 없는 계정입니다."), + LOGIN_REQUEST_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_016", "로그인에 실패하였습니다.") + ; private final HttpStatus httpStatus; private final String customCode;