fix(security): legacy /api/auth/login bypassed lockout and OTP enforcement

Delegate legacy login to TwoFactorService.secureLogin so the legacy path
inherits login-failure lockout and OTP policy. OTP-required accounts get
OTP_REQUIRED (no token, no challenge token) and must use /login/secure.
Web UI already uses secureLogin; mobile scaffold (uncommitted track) must
adopt the 2FA flow for staff roles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 03:55:13 +09:00
parent 8509787016
commit a657f9516a

View File

@ -7,7 +7,8 @@ import com.zioinfo.kintex.auth.dto.WorkspaceDto;
import com.zioinfo.kintex.auth.mapper.UserMapper;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.zioinfo.kintex.security.TwoFactorService;
import com.zioinfo.kintex.security.dto.SecureLoginResponse;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
@ -17,36 +18,34 @@ import java.util.Map;
/**
* 인증 서비스 UserMapper(인증행·행사 역할) 배선 완료. 로그인·워크스페이스·초대 수락을 처리한다.
*
* <p>보안(계약 §0-3): 비밀번호 BCrypt 해시와 대조({@link PasswordEncoder})하며, 해시·내부 식별자는 응답에 미노출.
* <p>보안(계약 §0-3): 비밀번호 대조는 위임된 secureLogin(BCrypt) 수행하며, 해시·내부 식별자는 응답에 미노출.
* 실패 메시지는 이메일/비밀번호를 구분하지 않는 일반화 문구만 반환한다.
*
* <p>2차 인증(OTP/TOTP)·로그인 실패 잠금은 common-dev(B-1) 레인이 별도로 얹으며 서비스는 건드리지 않는다.
* <p>레거시 /api/auth/login {@link TwoFactorService#secureLogin} 위임한다 위임하지 않으면
* 경로가 로그인 실패 잠금·OTP 강제를 모두 우회하는 구멍이 된다. OTP 대상 계정은 토큰 없이
* OTP_REQUIRED 거절되며(챌린지 토큰 미반환), 보안 로그인(/login/secure) 플로우만 허용된다.
*/
@Service
public class AuthServiceImpl implements AuthService {
private final JwtService jwtService;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final TwoFactorService twoFactorService;
public AuthServiceImpl(JwtService jwtService, UserMapper userMapper, PasswordEncoder passwordEncoder) {
public AuthServiceImpl(JwtService jwtService, UserMapper userMapper, TwoFactorService twoFactorService) {
this.jwtService = jwtService;
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
this.twoFactorService = twoFactorService;
}
@Override
public LoginResponse login(LoginRequest request) {
Map<String, Object> row = userMapper.findAuthByEmail(request.email());
String hash = row == null ? null : str(row.get("passwordHash"));
if (row == null || hash == null || !passwordEncoder.matches(request.password(), hash)) {
// 이메일/비밀번호 구분 없이 일반화(계정 열거 방지).
throw new ApiException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
SecureLoginResponse secured = twoFactorService.secureLogin(request.email(), request.password());
if (secured.login() == null) {
throw new ApiException(ErrorCode.OTP_REQUIRED,
"2차 인증이 필요한 계정입니다. 보안 로그인 절차를 이용해 주세요.");
}
String userId = str(row.get("userId"));
String displayName = str(row.get("displayName"));
boolean hallManager = Boolean.TRUE.equals(bool(row.get("hallManager")));
return buildLoginResponse(userId, displayName, hallManager);
return secured.login();
}
@Override