From 1409760aa3de7ccb0b08ae5da0a316b6cf2d01bd Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Sat, 4 Jul 2026 06:59:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OTP=202FA=20+=20admin=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=9E=AC=EC=8B=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 2 + .../zioinfo/mall/admin/AdminController.java | 6 + .../zioinfo/mall/admin/AdminUserService.java | 8 + .../mall/admin/mapper/AdminUserMapper.java | 3 + .../com/zioinfo/mall/auth/AuthController.java | 62 ++++++- .../com/zioinfo/mall/auth/AuthService.java | 45 ++++- .../java/com/zioinfo/mall/auth/MallUser.java | 4 +- .../mall/auth/dto/ChangePasswordRequest.java | 6 + .../mall/auth/dto/OtpConfirmRequest.java | 6 + .../mall/auth/dto/OtpSetupResponse.java | 10 ++ .../mall/auth/dto/OtpVerifyRequest.java | 6 + .../zioinfo/mall/auth/mapper/UserMapper.java | 21 +++ .../mall/config/AdminPasswordSeeder.java | 130 ++++++++++++++ .../mall/uiws/auth/OtpAuthService.java | 169 ++++++++++++++++++ .../zioinfo/mall/uiws/auth/TotpService.java | 81 +++++++++ .../mall/uiws/common/UiwsErrorCode.java | 4 + .../mall/uiws/config/UiwsProperties.java | 5 + backend/src/main/resources/application.yml | 2 +- backend/src/main/resources/db/93_auth_otp.sql | 14 ++ .../main/resources/db/ops_otp_reset_all.sql | 16 ++ .../main/resources/mapper/AdminUserMapper.xml | 1 + .../src/main/resources/mapper/UserMapper.xml | 7 +- frontend/index.html | 1 + frontend/public/favicon.ico | Bin 4286 -> 17542 bytes 24 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java create mode 100644 backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java create mode 100644 backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java create mode 100644 backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java create mode 100644 backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java create mode 100644 backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java create mode 100644 backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java create mode 100644 backend/src/main/resources/db/93_auth_otp.sql create mode 100644 backend/src/main/resources/db/ops_otp_reset_all.sql diff --git a/backend/pom.xml b/backend/pom.xml index e1e2786..da32c40 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -33,6 +33,8 @@ io.jsonwebtokenjjwt-impl${jjwt.version}runtime io.jsonwebtokenjjwt-jackson${jjwt.version}runtime org.springdocspringdoc-openapi-starter-webmvc-ui${springdoc.version} + + dev.samstevens.totptotp1.7.1 org.projectlomboklomboktrue org.springframework.bootspring-boot-starter-webflux org.springframework.bootspring-boot-starter-websocket diff --git a/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java b/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java index c9c3710..bb431da 100644 --- a/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java +++ b/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java @@ -56,6 +56,12 @@ public class AdminController { return ApiResponse.ok(userService.resetPassword(id, req.password())); } + /** 관리자 OTP 초기화(사용자 관리 화면 버튼) — 대상 OTP 시크릿 NULL → 다음 로그인 시 QR 재등록. */ + @PostMapping("/users/{id}/otp-reset") + public ApiResponse otpReset(@PathVariable Long id) { + return ApiResponse.ok(userService.otpReset(id)); + } + @DeleteMapping("/users/{id}") public ApiResponse deleteUser(@PathVariable Long id, Authentication auth) { String currentUsername = auth != null ? auth.getName() : null; diff --git a/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java b/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java index 32d508c..6ca5c51 100644 --- a/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java +++ b/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java @@ -83,6 +83,14 @@ public class AdminUserService { return UserDto.from(user); } + /** 관리자 OTP 초기화: 대상 사용자 시크릿·확정 플래그 폐기 → 다음 로그인 시 QR 재등록 유도. */ + public UserDto otpReset(Long id) { + MallUser user = require(id); + mapper.clearOtp(id); + auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(재등록 유도)"); + return UserDto.from(user); + } + public void delete(Long id, String currentUsername) { MallUser user = require(id); if (user.getUsername().equals(currentUsername)) { diff --git a/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java index e937b7c..a0fddba 100644 --- a/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java +++ b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java @@ -22,6 +22,9 @@ public interface AdminUserMapper { int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); + /** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */ + int clearOtp(@Param("id") Long id); + int deleteById(@Param("id") Long id); int countAdmins(); diff --git a/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java b/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java index 19058da..2ce0188 100644 --- a/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java +++ b/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java @@ -1,7 +1,15 @@ package com.zioinfo.mall.auth; +import com.zioinfo.mall.auth.dto.ChangePasswordRequest; +import com.zioinfo.mall.auth.dto.OtpConfirmRequest; +import com.zioinfo.mall.auth.dto.OtpSetupResponse; +import com.zioinfo.mall.auth.dto.OtpVerifyRequest; import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.uiws.auth.OtpAuthService; import com.zioinfo.mall.uiws.auth.TwoFactorService; +import com.zioinfo.mall.uiws.common.UiwsApiException; +import com.zioinfo.mall.uiws.common.UiwsErrorCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -20,6 +28,8 @@ public class AuthController { private final AuthService authService; private final TwoFactorService twoFactorService; + private final OtpAuthService otpAuthService; + private final JwtUtil jwtUtil; @PostMapping("/login") public ApiResponse> login(@RequestBody LoginRequest req) { @@ -31,18 +41,68 @@ public class AuthController { return ApiResponse.ok(authService.register(req.username(), req.password(), req.displayName())); } - /** UIWS 2FA 이식: 운영 로그인 2차 인증 코드 검증 → access 발급. */ + /** UIWS 2FA 이식: 운영 로그인 2차 인증 코드 검증(이메일) → access 발급. */ @PostMapping("/verify") public ApiResponse> verify(@RequestBody VerifyRequest req) { return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code())); } + /** TOTP 이식: 운영 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */ + @PostMapping("/verify-otp") + public ApiResponse> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) { + return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code())); + } + @GetMapping("/me") public ApiResponse> me(@RequestHeader("Authorization") String header) { String token = header.replace("Bearer ", ""); return ApiResponse.ok(authService.me(token)); } + // ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ────────── + // /api/mall/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증. + + /** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */ + @PostMapping("/otp/setup") + public ApiResponse otpSetup(@RequestHeader("Authorization") String header) { + return ApiResponse.ok(otpAuthService.setup(requireUser(header))); + } + + /** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */ + @PostMapping("/otp/confirm") + public ApiResponse> otpConfirm(@RequestHeader("Authorization") String header, + @Valid @RequestBody OtpConfirmRequest req) { + otpAuthService.confirm(requireUser(header), req.code()); + return ApiResponse.ok(Map.of("result", "ok")); + } + + /** 마이페이지 OTP 해제. */ + @PostMapping("/otp/disable") + public ApiResponse> otpDisable(@RequestHeader("Authorization") String header) { + otpAuthService.disable(requireUser(header)); + return ApiResponse.ok(Map.of("result", "ok")); + } + + /** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */ + @PostMapping("/change-password") + public ApiResponse> changePassword(@RequestHeader("Authorization") String header, + @Valid @RequestBody ChangePasswordRequest req) { + authService.changePassword(requireUser(header), req); + return ApiResponse.ok(Map.of("result", "ok")); + } + + /** + * Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부. + * (/api/mall/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.) + */ + private String requireUser(String header) { + String token = header == null ? "" : header.replace("Bearer ", "").trim(); + if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) { + throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED); + } + return jwtUtil.getUsername(token); + } + record LoginRequest(String username, String password) {} record RegisterRequest(String username, String password, String displayName) {} record VerifyRequest(String verifyToken, String code) {} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java b/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java index 9ab0675..fcced02 100644 --- a/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java +++ b/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java @@ -1,11 +1,14 @@ package com.zioinfo.mall.auth; +import com.zioinfo.mall.admin.AuditService; import com.zioinfo.mall.auth.dto.AuthHelperResult; +import com.zioinfo.mall.auth.dto.ChangePasswordRequest; import com.zioinfo.mall.auth.dto.FindIdRequest; import com.zioinfo.mall.auth.dto.FindIdResponse; import com.zioinfo.mall.auth.dto.ResetPasswordRequest; import com.zioinfo.mall.auth.dto.SignupRequest; import com.zioinfo.mall.auth.mapper.UserMapper; +import com.zioinfo.mall.uiws.auth.OtpAuthService; import com.zioinfo.mall.uiws.auth.TwoFactorService; import com.zioinfo.mall.uiws.common.UiwsApiException; import com.zioinfo.mall.uiws.common.UiwsErrorCode; @@ -42,7 +45,9 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final TwoFactorService twoFactorService; + private final OtpAuthService otpAuthService; private final MailSender mailSender; + private final AuditService auditService; /** 운영(2FA 대상) 역할 여부 — 고객(USER)은 제외. */ private static boolean isOperationsRole(String role) { @@ -70,11 +75,14 @@ public class AuthService { throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다."); } - boolean twofaTarget = twoFactorService.isEnabled() && isOperationsRole(user.getRole()); + boolean opsRole = isOperationsRole(user.getRole()); + // 2단계 인증 대상: 운영(ADMIN/MANAGER) 로그인만. OTP(TOTP) 우선, 없으면 이메일코드. + boolean otpTarget = otpAuthService.isEnabled() && opsRole; + boolean emailTarget = twoFactorService.isEnabled() && opsRole; if (!passwordEncoder.matches(password, user.getPasswordHash())) { - // 운영 + 2FA 활성 시 실패 누적/잠금. 고객/비활성 시 기존 동작(메시지만) 유지. - if (twofaTarget) { + // 운영 + 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 고객/비활성 시 기존 동작(메시지만) 유지. + if (otpTarget || emailTarget) { twoFactorService.recordLoginFailure(username); MallUser after = userMapper.findByUsername(username); if (after != null && Boolean.TRUE.equals(after.getLocked())) { @@ -84,8 +92,12 @@ public class AuthService { throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); } - // 비밀번호 검증 통과 - if (twofaTarget) { + // 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인 + if (otpTarget) { + // { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? } + return otpAuthService.beginOtp(user); + } + if (emailTarget) { Map step1 = twoFactorService.beginTwoFactor(user); return Map.of( "twofa", "true", @@ -95,7 +107,7 @@ public class AuthService { } // 고객(USER) 또는 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0) - if (isOperationsRole(user.getRole())) { + if (opsRole) { userMapper.resetLoginFail(username); } String token = jwtUtil.generate(username, user.getRole()); @@ -127,6 +139,27 @@ public class AuthService { return Map.of("username", username, "role", role); } + /** + * 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러. + * 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD. + * 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙). + */ + @Transactional + public void changePassword(String username, ChangePasswordRequest req) { + MallUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); + } + if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) { + throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH); + } + if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) { + throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD); + } + userMapper.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword())); + auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경"); + } + // ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────────── // 대상: Mall 관리자/운영자 계정(mall_account, ADMIN/MANAGER). 고객(USER) register 흐름과 분리. diff --git a/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java index 08e0e12..125d46d 100644 --- a/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java +++ b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java @@ -26,8 +26,10 @@ public class MallUser { private Integer loginFailCount; /** 계정 잠금 여부(기본 false). */ private Boolean locked; - /** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */ + /** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */ private String otpSecret; + /** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */ + private Boolean otpEnabled; // ── 로그인 보조 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql) ─────────────── /** diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..b3e83f9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/ChangePasswordRequest.java @@ -0,0 +1,6 @@ +package com.zioinfo.mall.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 마이페이지 비밀번호 변경 요청(현재 비번 검증 + 새 비번). 평문은 응답/로그 미노출. */ +public record ChangePasswordRequest(@NotBlank String currentPassword, @NotBlank String newPassword) {} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java new file mode 100644 index 0000000..78298f4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpConfirmRequest.java @@ -0,0 +1,6 @@ +package com.zioinfo.mall.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 마이페이지 OTP 확인·활성화 요청(앱 6자리 코드). */ +public record OtpConfirmRequest(@NotBlank String code) {} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java new file mode 100644 index 0000000..444e8ab --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpSetupResponse.java @@ -0,0 +1,10 @@ +package com.zioinfo.mall.auth.dto; + +/** + * OTP 등록/재설정 시작 응답 — 이 응답에서만 시크릿/QR/otpauth URI 를 노출한다(보안 불변). + * + * @param secret TOTP 시크릿(Base32). 로그/그 외 응답 미노출. + * @param otpAuthUri Authenticator 앱 직접 등록용 otpauth:// URI. + * @param qrImage QR 이미지 data URI(data:image/png;base64,...). + */ +public record OtpSetupResponse(String secret, String otpAuthUri, String qrImage) {} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java new file mode 100644 index 0000000..8b1993a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/dto/OtpVerifyRequest.java @@ -0,0 +1,6 @@ +package com.zioinfo.mall.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 로그인 2단계 OTP 검증 요청: verify-token + 앱 6자리 코드. */ +public record OtpVerifyRequest(@NotBlank String verifyToken, @NotBlank String code) {} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java index 8931c2d..e838e28 100644 --- a/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java +++ b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java @@ -53,6 +53,27 @@ public interface UserMapper { @Update("UPDATE mall_account SET locked = false, login_fail_count = 0 WHERE username = #{username}") int unlock(@Param("username") String username); + // ── admin 재시드 / 마이페이지 비밀번호 변경: password_hash 만 갱신(부수효과 없음) ──────── + + /** username 기준 BCrypt 해시 갱신(잠금/실패카운트 무영향). AdminPasswordSeeder·changePassword 공용. */ + @Update("UPDATE mall_account SET password_hash = #{passwordHash} WHERE username = #{username}") + int updatePasswordByUsername(@Param("username") String username, + @Param("passwordHash") String passwordHash); + + // ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ──────────────────────────────────── + + /** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */ + @Update("UPDATE mall_account SET otp_secret = #{secret} WHERE username = #{username}") + int updateOtpSecret(@Param("username") String username, @Param("secret") String secret); + + /** 등록 확정: otp_enabled=true (시크릿은 유지). */ + @Update("UPDATE mall_account SET otp_enabled = true WHERE username = #{username}") + int enableOtp(@Param("username") String username); + + /** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */ + @Update("UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}") + int disableOtp(@Param("username") String username); + // ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (UserMapper.xml) ─────── /** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */ diff --git a/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java b/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java new file mode 100644 index 0000000..b320015 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/config/AdminPasswordSeeder.java @@ -0,0 +1,130 @@ +package com.zioinfo.mall.config; + +import com.zioinfo.mall.auth.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Base64; + +/** + * admin 비밀번호 암호화 재시드(UIMS 방식 + 암호화 저장). — OCR 파일럿 미러. + * + *

기동 시 멱등 재시드: + *

+ *   ADMIN_KEY_FILE(hex 32바이트 키 파일) 로드 → ADMIN_PASSWORD_ENC(base64(nonce12 + ct + tag), AES-256-GCM) 복호
+ *   → admin 계정 BCrypt 해시 갱신(하드코딩 admin123 시드를 env 값으로 덮어씀).
+ * 
+ * + *

안전 규칙: + *

    + *
  • env 미설정 / 키 로드 실패 / 복호 실패 시 재시드 스킵(기동 계속). WARN 로그에 값(평문/키/시크릿) 미기록.
  • + *
  • 새 admin 비밀번호 값은 서버 env 에만 존재 — 코드/DB/로그/응답에 평문 미기재(보안 불변규칙).
  • + *
  • 키 값은 별도 파일(root 600)에만 — env/코드/git 에 키 미기재. 여기서는 파일 경로만 읽는다.
  • + *
+ * + *

스키마 init(admin/admin123 시드) 이후 실행되어 기존 해시를 덮어쓴다(멱등: 매 기동 동일 결과). + */ +@Slf4j +@Component +@Order(Integer.MIN_VALUE + 10) +@RequiredArgsConstructor +public class AdminPasswordSeeder implements ApplicationRunner { + + private static final String ADMIN_USERNAME = "admin"; + private static final int GCM_TAG_BITS = 128; + private static final int NONCE_LEN = 12; + + @Value("${ADMIN_PASSWORD_ENC:}") + private String adminPasswordEnc; + + @Value("${ADMIN_KEY_FILE:}") + private String adminKeyFile; + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + + @Override + public void run(ApplicationArguments args) { + if (isBlank(adminPasswordEnc) || isBlank(adminKeyFile)) { + log.info("[admin-reseed] ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE 미설정 — 재시드 스킵(기동 계속)"); + return; + } + char[] plain = null; + byte[] key = null; + try { + key = loadHexKey(adminKeyFile); // hex 32바이트 → 32B (AES-256) + plain = decrypt(adminPasswordEnc, key); // base64(nonce12+ct+tag) → 평문 + String hash = passwordEncoder.encode(new String(plain)); + int updated = userMapper.updatePasswordByUsername(ADMIN_USERNAME, hash); + if (updated > 0) { + log.info("[admin-reseed] admin 비밀번호 env 값으로 재시드 완료(멱등)"); + } else { + log.warn("[admin-reseed] admin 계정 미존재 — 재시드 스킵"); + } + } catch (Exception e) { + // 값 미기록: 예외 클래스명만(메시지에 평문/키가 섞일 여지 차단) + log.warn("[admin-reseed] 복호/재시드 실패 — 스킵(기동 계속). cause={}", e.getClass().getSimpleName()); + } finally { + if (plain != null) Arrays.fill(plain, '\0'); + if (key != null) Arrays.fill(key, (byte) 0); + } + } + + /** 키 파일(hex 문자열, 공백/개행 무시)을 32바이트 키로 로드. 32바이트가 아니면 예외. */ + private static byte[] loadHexKey(String path) throws Exception { + String hex = Files.readString(Path.of(path), StandardCharsets.UTF_8) + .replaceAll("\\s", ""); + byte[] key = hexToBytes(hex); + if (key.length != 32) { + throw new IllegalStateException("key length != 32 bytes"); + } + return key; + } + + /** base64(nonce12 + ciphertext + tag) → AES-256-GCM 복호 평문(char[]). */ + private static char[] decrypt(String encBase64, byte[] key) throws Exception { + byte[] blob = Base64.getDecoder().decode(encBase64.trim()); + if (blob.length <= NONCE_LEN) { + throw new IllegalStateException("cipher blob too short"); + } + byte[] nonce = Arrays.copyOfRange(blob, 0, NONCE_LEN); + byte[] ct = Arrays.copyOfRange(blob, NONCE_LEN, blob.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), + new GCMParameterSpec(GCM_TAG_BITS, nonce)); + byte[] out = cipher.doFinal(ct); + char[] chars = new String(out, StandardCharsets.UTF_8).toCharArray(); + Arrays.fill(out, (byte) 0); + return chars; + } + + private static byte[] hexToBytes(String hex) { + int len = hex.length(); + if (len % 2 != 0) { + throw new IllegalStateException("odd hex length"); + } + byte[] out = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + out[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return out; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java b/backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java new file mode 100644 index 0000000..5726476 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/uiws/auth/OtpAuthService.java @@ -0,0 +1,169 @@ +package com.zioinfo.mall.uiws.auth; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.auth.JwtUtil; +import com.zioinfo.mall.auth.MallUser; +import com.zioinfo.mall.auth.dto.OtpSetupResponse; +import com.zioinfo.mall.auth.mapper.UserMapper; +import com.zioinfo.mall.uiws.common.UiwsApiException; +import com.zioinfo.mall.uiws.common.UiwsErrorCode; +import com.zioinfo.mall.uiws.config.UiwsProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * TOTP(OTP 2차 인증) 오케스트레이션 레이어 — UIWS OTP 경로 미러(OCR 파일럿 패턴). + * + *

★ Mall 은 운영(ADMIN/MANAGER) 로그인에만 이 레이어를 태운다. 고객(USER) 쇼핑 로그인은 무변경. + * + *

흐름: + *

    + *
  1. 1차 로그인 성공 → {@link #beginOtp}: verify-token 발급 + (미등록이면 보류 시크릿+QR) 반환.
  2. + *
  3. {@code POST /verify-otp}(verifyToken+code) → {@link #verifyOtp}: 6자리 검증 후 access/refresh 발급. + * 최초 로그인이면 등록 확정(otp_enabled=true).
  4. + *
  5. 마이페이지: {@link #setup}/{@link #confirm}/{@link #disable}.
  6. + *
+ * + *

보안 불변: 시크릿·QR·otpauth URI 는 setup/OTP_SETUP 응답에서만 노출. 로그·감사에 시크릿/코드 미기록. + * 기존 auth(JWT·RBAC) 엔진 교체 없음 — TOTP 레이어만 추가. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OtpAuthService { + + private final UserMapper userMapper; + private final JwtUtil jwtUtil; + private final TotpService totpService; + private final UiwsProperties properties; + private final AuditService auditService; + + /** 로그인 2단계에 OTP 경로를 사용할지(전역 토글). 실제 적용 대상 게이팅은 AuthService 가 운영 역할로 한정. */ + public boolean isEnabled() { + return properties.getAuth().isOtpEnabled(); + } + + private static boolean blank(String s) { + return s == null || s.isBlank(); + } + + /** + * 1차 로그인 성공 후 OTP 2단계 시작. + * @return 미등록: { twofa:true, verifyToken, verifyMethod:OTP_SETUP, secret, otpAuthUri, qrImage } + * 등록됨: { twofa:true, verifyToken, verifyMethod:OTP } + */ + @Transactional + public Map beginOtp(MallUser user) { + long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds(); + String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity); + // 성공 시 실패카운트 초기화(잠금 회복) + userMapper.resetLoginFail(user.getUsername()); + + Map resp = new LinkedHashMap<>(); + resp.put("twofa", "true"); + resp.put("verifyToken", verifyToken); + + if (blank(user.getOtpSecret())) { + // 미등록 최초 로그인 — 보류 시크릿 발급 + QR(이 응답에서만 노출) + String secret = totpService.generateSecret(); + userMapper.updateOtpSecret(user.getUsername(), secret); + resp.put("verifyMethod", "OTP_SETUP"); + resp.put("secret", secret); + resp.put("otpAuthUri", totpService.otpAuthUri(secret, user.getUsername())); + resp.put("qrImage", totpService.qrImageDataUri(secret, user.getUsername())); + } else { + resp.put("verifyMethod", "OTP"); + } + return resp; + } + + /** + * 2단계 검증: verify-token + 6자리 코드 → access/refresh 발급. 최초 로그인이면 등록 확정. + * @return { token, refreshToken, type, username, role } + */ + @Transactional + public Map verifyOtp(String verifyToken, String code) { + String username = jwtUtil.parseVerifyTokenUsername(verifyToken); + if (username == null) { + throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID); + } + MallUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID); + } + if (Boolean.TRUE.equals(user.getLocked())) { + throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED); + } + if (!totpService.verify(user.getOtpSecret(), code)) { + // OTP 오입력도 실패 카운트에 합산(정책) — 임계 도달 시 잠금. + userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail()); + throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID); + } + + // 최초 로그인(보류 시크릿) → 등록 확정(멱등) + if (!Boolean.TRUE.equals(user.getOtpEnabled())) { + userMapper.enableOtp(username); + auditService.log(username, "OTP_ENROLL", username, "최초 로그인 OTP 등록 확정"); + } + userMapper.resetLoginFail(username); + + String access = jwtUtil.generate(user.getUsername(), user.getRole()); + String refresh = jwtUtil.generate(user.getUsername(), user.getRole()); + Map resp = new LinkedHashMap<>(); + resp.put("token", access); + resp.put("refreshToken", refresh); + resp.put("type", "Bearer"); + resp.put("username", user.getUsername()); + resp.put("role", user.getRole() == null ? "" : user.getRole()); + return resp; + } + + /** 마이페이지 OTP 등록/재설정 시작: 새 보류 시크릿 발급(기존 시크릿 무효화) + QR 반환. */ + @Transactional + public OtpSetupResponse setup(String username) { + MallUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); + } + String secret = totpService.generateSecret(); + userMapper.updateOtpSecret(username, secret); // 확정 전엔 otp_enabled 유지(재설정 시 confirm 재확정) + auditService.log(username, "OTP_SETUP", username, "OTP 등록/재설정 시작(새 시크릿 발급)"); + return new OtpSetupResponse( + secret, + totpService.otpAuthUri(secret, username), + totpService.qrImageDataUri(secret, username)); + } + + /** 마이페이지 OTP 확인·활성화: 앱 코드 검증 성공 시 otp_enabled=true. */ + @Transactional + public void confirm(String username, String code) { + MallUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); + } + if (blank(user.getOtpSecret())) { + throw new UiwsApiException(UiwsErrorCode.OTP_NOT_REGISTERED); + } + if (!totpService.verify(user.getOtpSecret(), code)) { + throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID); + } + userMapper.enableOtp(username); + auditService.log(username, "OTP_ENABLE", username, "OTP 2차 인증 활성화"); + } + + /** 마이페이지 OTP 해제: 시크릿 폐기 + otp_enabled=false. */ + @Transactional + public void disable(String username) { + MallUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); + } + userMapper.disableOtp(username); + auditService.log(username, "OTP_DISABLE", username, "OTP 2차 인증 해제"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java b/backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java new file mode 100644 index 0000000..f370f2a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/uiws/auth/TotpService.java @@ -0,0 +1,81 @@ +package com.zioinfo.mall.uiws.auth; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrGenerator; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import dev.samstevens.totp.util.Utils; +import org.springframework.stereotype.Service; + +/** + * TOTP(RFC 6238, HMAC-SHA1, 30s, 6자리, ±1 윈도우) 코덱 — UIWS TotpService 미러(OCR 파일럿 패턴). + * 시크릿(otp_secret)은 mall_account 에 저장되어 있다고 가정. issuer 만 GUARDiA-MALL 로 교체. + * + *

보안 불변: 시크릿·otpauth URI·QR 은 등록 순간 발급 응답에서만 노출. 로그 기록 금지. + */ +@Service +public class TotpService { + + private final TimeProvider timeProvider = new SystemTimeProvider(); + private final CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6); + private final SecretGenerator secretGenerator = new DefaultSecretGenerator(); + private final CodeVerifier codeVerifier = buildVerifier(); + private final QrGenerator qrGenerator = new ZxingPngQrGenerator(); + + private static final String ISSUER = "GUARDiA-MALL"; + + private CodeVerifier buildVerifier() { + DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + verifier.setTimePeriod(30); + verifier.setAllowedTimePeriodDiscrepancy(1); // ±1 윈도우 허용(시계 오차) + return verifier; + } + + /** 신규 OTP 시크릿 생성(최초 로그인/마이페이지 OTP 등록용). */ + public String generateSecret() { + return secretGenerator.generate(); + } + + /** 주어진 시크릿에 대해 사용자 입력 코드가 유효한지 검증. */ + public boolean verify(String secret, String code) { + if (secret == null || secret.isBlank() || code == null || code.isBlank()) { + return false; + } + return codeVerifier.isValidCode(secret, code.trim()); + } + + /** Authenticator 앱 직접 등록용 otpauth:// URI. */ + public String otpAuthUri(String secret, String userId) { + return buildQrData(secret, userId).getUri(); + } + + /** QR 이미지(data:image/png;base64,...) — 앱으로 스캔해 등록. */ + public String qrImageDataUri(String secret, String userId) { + try { + byte[] image = qrGenerator.generate(buildQrData(secret, userId)); + return Utils.getDataUriForImage(image, qrGenerator.getImageMimeType()); + } catch (QrGenerationException e) { + throw new IllegalStateException("OTP QR 생성 실패", e); + } + } + + private QrData buildQrData(String secret, String userId) { + return new QrData.Builder() + .label(userId) + .secret(secret) + .issuer(ISSUER) + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java b/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java index fb44dba..5ddcce8 100644 --- a/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java +++ b/backend/src/main/java/com/zioinfo/mall/uiws/common/UiwsErrorCode.java @@ -37,6 +37,10 @@ public enum UiwsErrorCode { VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."), VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."), ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."), + UNAUTHORIZED("ERR-UIWS-2FA-401U", "인증이 필요합니다."), + OTP_NOT_REGISTERED("ERR-UIWS-OTP-409", "OTP 가 등록되어 있지 않습니다."), + PASSWORD_MISMATCH("ERR-UIWS-PW-401", "현재 비밀번호가 일치하지 않습니다."), + PASSWORD_SAME_AS_OLD("ERR-UIWS-PW-409", "새 비밀번호가 기존 비밀번호와 동일합니다."), // system(시스템관리·권한관리) 이식 DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."), diff --git a/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java b/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java index 19f24d4..eeed501 100644 --- a/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java +++ b/backend/src/main/java/com/zioinfo/mall/uiws/config/UiwsProperties.java @@ -24,6 +24,11 @@ public class UiwsProperties { public static class Auth { /** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */ private boolean twofaEnabled = true; + /** + * OTP(TOTP) 2차 인증 경로 on/off. 기본 on(켜짐) — 운영(ADMIN/MANAGER) 로그인에 우선 적용. + * on 이면 이메일코드보다 우선(OTP > 이메일 > 단일). 고객(USER) 쇼핑 로그인은 무관(회귀 0). + */ + private boolean otpEnabled = true; /** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */ private long verifyTokenValiditySeconds = 300; /** 이메일 인증코드 유효시간(초). 계획서 300(5분). */ diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8042761..a0595f7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: sql: init: mode: ${SQL_INIT_MODE:always} - schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/104_seed_ai_config.sql + schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql continue-on-error: true servlet: multipart: diff --git a/backend/src/main/resources/db/93_auth_otp.sql b/backend/src/main/resources/db/93_auth_otp.sql new file mode 100644 index 0000000..c003195 --- /dev/null +++ b/backend/src/main/resources/db/93_auth_otp.sql @@ -0,0 +1,14 @@ +-- ===================================================================== +-- TOTP(OTP 2차 인증) 이식 — mall_account 멱등 ALTER (OCR 파일럿 미러) +-- ===================================================================== +-- otp_secret 은 db/91_uiws_port.sql 에서 이미 ADD(VARCHAR(255)). +-- 여기서는 등록 확정 플래그 otp_enabled 만 추가한다. +-- schema-locations 마지막에 등재(mode=always + continue-on-error) → 멱등 재실행 안전. +-- ※ 2FA/OTP 는 운영(ADMIN/MANAGER) 로그인에만 적용 — 고객(USER) 쇼핑 로그인 회귀 0. + +SET client_encoding = 'UTF8'; + +ALTER TABLE mall_account ADD COLUMN IF NOT EXISTS otp_enabled BOOLEAN DEFAULT false; + +COMMENT ON COLUMN mall_account.otp_secret IS 'TOTP 시크릿(보류/확정 공용). API 응답·로그 미노출.'; +COMMENT ON COLUMN mall_account.otp_enabled IS 'OTP 등록 확정 여부(최초 로그인 verify 성공 시 true).'; diff --git a/backend/src/main/resources/db/ops_otp_reset_all.sql b/backend/src/main/resources/db/ops_otp_reset_all.sql new file mode 100644 index 0000000..e86e329 --- /dev/null +++ b/backend/src/main/resources/db/ops_otp_reset_all.sql @@ -0,0 +1,16 @@ +-- ===================================================================== +-- [1회 운영 작업] 전 사용자 OTP 초기화 — 멱등 (OCR 파일럿 미러) +-- ===================================================================== +-- 목적: 인증 강화 전환 시점에 기존 사용자 OTP 를 초기화한다. +-- 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 타게 한다(UIMS 방식). +-- +-- ★ 이 파일은 schema-locations 에 등재하지 않는다(자동 재실행 금지). +-- 운영자가 서버에서 1회 수동 적용: +-- psql -U mall_user -d mall_db -f db/ops_otp_reset_all.sql +-- +-- 멱등: 여러 번 실행해도 결과 동일(모두 NULL/false). + +UPDATE mall_account + SET otp_secret = NULL, + otp_enabled = false + WHERE otp_secret IS NOT NULL OR otp_enabled IS DISTINCT FROM false; diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml index 0279811..37a801c 100644 --- a/backend/src/main/resources/mapper/AdminUserMapper.xml +++ b/backend/src/main/resources/mapper/AdminUserMapper.xml @@ -32,6 +32,7 @@ UPDATE mall_account SET role = #{role} WHERE id = #{id} UPDATE mall_account SET is_active = #{active} WHERE id = #{id} UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id} + UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE id = #{id} DELETE FROM mall_account WHERE id = #{id} SELECT id, username, password_hash, role, display_name, is_active, created_at, - email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, approved + email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved FROM mall_account WHERE username = #{username} @@ -56,7 +57,7 @@ SELECT id, username, password_hash, role, display_name, is_active, created_at, - email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, approved + email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved FROM mall_account WHERE username = #{username} AND email = #{email} diff --git a/frontend/index.html b/frontend/index.html index c347999..cb46eea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 93b6ea6f38f33dd5e6b2085c7887c44cafba1783..3252b9f8cf91fd91df2906be1d4578269c46036f 100644 GIT binary patch literal 17542 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 SPrKLOyq3Ug3H<+-!2bZ4KjJX} literal 4286 zcmeHLTTC2P7(Rp;AAG2ZiTYr)PsSJ1_)vYsL=$OHTWf1lqvcXsENT&2N{vZvZEE_^ z7;m*Mx1|MIAT5v<6tPl>rL`2wwaYHoT^6W<%r5MGcecOZKeN*=n`K$5hL;Zc_n&j- z%zWSZuQLddfWNhC1^G?2=N>QQ{rU^&npM6J@$wy>VyzP^gve82DiEcqU)Mp z^mY10?~QxXk8w|jI_C46*VQ!>W3m_>3W&N2clyzN3&WLq*Vy$dzO1o9S(r_-F!lw6 zp*J7~dji4;&BEt{n%2%SHeu~@_{^aXyhcm`F=>&-<)6G8j_qIQOy4j^A8wVX^4IgZlXy=;}<-TX9Pj}g= zqSQszl`bj_TRW-IOqq49dz8cSY(hld$5Al9KddEfuJAla&!e+e`emD<3 z?4SdWno74B{>$RHFmr4$j}SiWD~tmHF+C}ZwkGeZ$3I(W%Y=Wk-jWnd1v?B^#)qStVDDvx- zBf0ZN_H%e_MZ=4nyC3{;9_)|m3b#3LyG<^L;@?QT$nn3y*LWaborr5k{wm;eJis5b zs3r6H>5FqKa<9?Gdr^n>B2M)>7uB3mb6?w6JUWCwvr>Gnefxl$nk+%vl(;TMo`;^N z)S&+ZU#mg8HcGSuIr`N>2X$TXk*PmG4Yh8i{&NptY>xBgIK;hw)B(@7A*+aS>w zx$|`Cf|q7*2kB31kWkME^@MJ;`{?{xH|nlK$?@el#DmYcIp{m7ugxjFroO^WljAZ; ze+6lHAV8>JilD!cbwpOUv|skp+0!mka=dtHTg1X=T+XjF)TA8ro$?bdLJuMP-$5F~ ze$mGW?+%NExiF`xQCZO~O=+h4XHu{%bL+;o}{~oxN3!t;2ifskz=uGgCpDnGP!U<>$n}>A|(m7}u41 z(Ps$nA=H6Xzz1GjQ^g)Od5|u0O zAv{KZZ}BSmi5}o9{n~k){bZXB(zq!uAD;rw(+~|?(@$cqa48Nv&pdc