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
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,8 +27,13 @@ public class AuthController {

@Operation(summary = "로그인 API")
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
return ResponseEntity.ok(authUsecase.login(request.nickname(), request.password()));
public ResponseEntity<LoginResponse> 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);
}


}
Original file line number Diff line number Diff line change
@@ -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<LoginLog> findBySessionId(String sessionId) {
return loginLogRepository.findById(sessionId).map(loginLogMapper::toDomain);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package clap.server.adapter.outbound.infrastructure.redis.log;

import org.springframework.data.repository.CrudRepository;

public interface LoginLogRepository extends CrudRepository<LoginLogEntity, String> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<LoginLog> findBySessionId(String sessionId);
}
46 changes: 27 additions & 19 deletions src/main/java/clap/server/application/service/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,55 @@
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 {
private final LoadMemberPort loadMemberPort;
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);
}
}

}

Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading