zioinfo-esn/backend/src/main/java/com/zioinfo/esn/auth/AuthHelperService.java

133 lines
6.5 KiB
Java

package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.dto.FindIdRequest;
import com.zioinfo.esn.auth.dto.FindIdResponse;
import com.zioinfo.esn.auth.dto.ResetPwRequest;
import com.zioinfo.esn.auth.dto.SignupRequest;
import com.zioinfo.esn.auth.dto.SignupResponse;
import com.zioinfo.esn.auth.mapper.UserSignupMapper;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
/**
* 로그인 보조 3종(UIWS auth 패턴 이식) — 회원가입 / 아이디찾기 / 비밀번호 초기화.
* 기존 2FA 로그인 서비스(AuthService)와 별개 — 회귀 0.
*
* <p>보안 불변규칙: 임시비번·자격증명·계정 존재여부를 응답/로그 메시지에 노출하지 않는다.
* 메일 발송은 MailSender(미설정 시 LogMailSender 폴백)만 사용 — 외부 API 호출 없음.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthHelperService {
private final UserSignupMapper userSignupMapper;
private final PasswordEncoder passwordEncoder;
private final MailSender mailSender;
private static final SecureRandom RANDOM = new SecureRandom();
// 혼동 문자(0/O/1/l/I) 제외 — 임시비번 가독성.
private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
/**
* 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false.
* 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만.
*/
public SignupResponse signup(SignupRequest req) {
if (req.username() == null || req.username().isBlank()
|| req.password() == null || req.password().length() < 4
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
}
if (userSignupMapper.countByUsername(req.username()) > 0) {
return new SignupResponse(false, "이미 사용 중인 아이디입니다.");
}
if (userSignupMapper.countByEmail(req.email()) > 0) {
return new SignupResponse(false, "이미 등록된 이메일입니다.");
}
EsnUser u = new EsnUser();
u.setUsername(req.username());
u.setPasswordHash(passwordEncoder.encode(req.password()));
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
? req.displayName() : req.username());
u.setEmail(req.email());
// insertSignup: role='USER', approved=false, locked=false, login_fail_count=0 (XML 고정)
userSignupMapper.insertSignup(u);
log.info("[auth-helper] signup pending approval: username={}", req.username());
return new SignupResponse(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
}
/**
* 아이디 찾기: 표시명+이메일 동시 일치 계정 1건 조회. username 은 부분 마스킹 후 반환.
* 미발견 시 found=false(원문 username 절대 미노출).
*/
public FindIdResponse findId(FindIdRequest req) {
if (req.displayName() == null || req.displayName().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new FindIdResponse(false, "");
}
EsnUser u = userSignupMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
if (u == null) {
return new FindIdResponse(false, "");
}
return new FindIdResponse(true, maskUsername(u.getUsername()));
}
/**
* 비밀번호 초기화: username+email 일치 검증 → 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제·변경유도.
* 임시비번은 메일(미설정 시 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
* 대상 미존재여도 success=true(계정 열거 방지).
*/
@Transactional
public SignupResponse resetPassword(ResetPwRequest req) {
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
if (req.username() == null || req.username().isBlank()
|| req.email() == null || req.email().isBlank()) {
return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요.");
}
EsnUser u = userSignupMapper.findByUsernameAndEmail(req.username(), req.email());
if (u == null) {
// 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음).
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
return new SignupResponse(true, okMsg);
}
String tempPw = generateTempPassword();
// applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등)
userSignupMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw));
String subject = "[zioinfo-esn] 임시 비밀번호 안내";
String body = String.format(
"안녕하세요 %s 님,%n임시 비밀번호는 [%s] 입니다.%n로그인 후 즉시 비밀번호를 변경하세요.",
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
mailSender.send(u.getEmail(), subject, body);
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
return new SignupResponse(true, okMsg);
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
}