|
|
@@ -11,6 +11,199 @@ class UsersController extends BaseApiController
|
|
|
|
|
|
private const ALLOWED_AREAS = ['남해', '동해', '민물', '서해', '제주'];
|
|
|
|
|
|
+ /**
|
|
|
+ * 아이디 찾기 — 이름 + 핸드폰으로 조회 → 마스킹된 아이디 반환
|
|
|
+ * POST /api/users/find-id
|
|
|
+ */
|
|
|
+ public function findId()
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $payload = $this->request->getJSON(true);
|
|
|
+ $name = trim((string) ($payload['name'] ?? ''));
|
|
|
+ $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
|
|
|
+
|
|
|
+ if ($name === '') return $this->respondError('이름을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
|
|
|
+ return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+
|
|
|
+ $phoneFormatted = $this->formatPhone($phoneDigits);
|
|
|
+
|
|
|
+ $user = $this->getDB()->table($this->table)
|
|
|
+ ->where('name', $name)
|
|
|
+ ->where('phone', $phoneFormatted)
|
|
|
+ ->where('deleted_YN', 'N')
|
|
|
+ ->get()
|
|
|
+ ->getRow();
|
|
|
+
|
|
|
+ if (!$user) {
|
|
|
+ return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->respondSuccess([
|
|
|
+ 'username_masked' => $this->maskUsername($user->username),
|
|
|
+ 'signup_type' => $user->signup_type,
|
|
|
+ 'created_at' => $user->created_at,
|
|
|
+ ], '아이디 조회 성공');
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ log_message('error', 'findId error: ' . $e->getMessage());
|
|
|
+ return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 비밀번호 재설정 — 본인 확인 (아이디+이름+핸드폰)
|
|
|
+ * POST /api/users/verify-for-reset
|
|
|
+ * 일치하면 10분 짜리 임시 토큰 발급
|
|
|
+ */
|
|
|
+ public function verifyForReset()
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $payload = $this->request->getJSON(true);
|
|
|
+ $username = trim((string) ($payload['username'] ?? ''));
|
|
|
+ $name = trim((string) ($payload['name'] ?? ''));
|
|
|
+ $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
|
|
|
+
|
|
|
+ if ($username === '' || $name === '') {
|
|
|
+ return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+ if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
|
|
|
+ return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+
|
|
|
+ $phoneFormatted = $this->formatPhone($phoneDigits);
|
|
|
+ $db = $this->getDB();
|
|
|
+
|
|
|
+ $user = $db->table($this->table)
|
|
|
+ ->where('username', $username)
|
|
|
+ ->where('name', $name)
|
|
|
+ ->where('phone', $phoneFormatted)
|
|
|
+ ->where('deleted_YN', 'N')
|
|
|
+ ->get()
|
|
|
+ ->getRow();
|
|
|
+
|
|
|
+ if (!$user) {
|
|
|
+ return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 소셜 가입자는 비밀번호 재설정 불가
|
|
|
+ if ($user->signup_type !== 'local') {
|
|
|
+ return $this->respondError(
|
|
|
+ '소셜 가입 회원입니다. (' . $user->signup_type . ')',
|
|
|
+ ResponseInterface::HTTP_BAD_REQUEST
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 10분짜리 임시 토큰 발급
|
|
|
+ $token = bin2hex(random_bytes(32));
|
|
|
+ $expiresAt = date('Y-m-d H:i:s', strtotime('+10 minutes'));
|
|
|
+ $db->table('user_tokens')->insert([
|
|
|
+ 'user_id' => $user->id,
|
|
|
+ 'token' => $token,
|
|
|
+ 'expires_at' => $expiresAt,
|
|
|
+ 'created_at' => date('Y-m-d H:i:s'),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $this->respondSuccess([
|
|
|
+ 'reset_token' => $token,
|
|
|
+ 'expires_at' => $expiresAt,
|
|
|
+ ], '본인 확인 완료');
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ log_message('error', 'verifyForReset error: ' . $e->getMessage());
|
|
|
+ return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 비밀번호 재설정 실제 적용
|
|
|
+ * POST /api/users/reset-password
|
|
|
+ */
|
|
|
+ public function resetPassword()
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $payload = $this->request->getJSON(true);
|
|
|
+ $resetToken = trim((string) ($payload['reset_token'] ?? ''));
|
|
|
+ $newPassword = (string) ($payload['new_password'] ?? '');
|
|
|
+
|
|
|
+ if ($resetToken === '' || $newPassword === '') {
|
|
|
+ return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+ if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $newPassword)) {
|
|
|
+ return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+
|
|
|
+ $db = $this->getDB();
|
|
|
+ $tokenRow = $db->table('user_tokens')
|
|
|
+ ->where('token', $resetToken)
|
|
|
+ ->where('expires_at >', date('Y-m-d H:i:s'))
|
|
|
+ ->get()
|
|
|
+ ->getRow();
|
|
|
+
|
|
|
+ if (!$tokenRow) {
|
|
|
+ return $this->respondError('유효하지 않거나 만료된 인증입니다.', ResponseInterface::HTTP_UNAUTHORIZED);
|
|
|
+ }
|
|
|
+
|
|
|
+ $user = $db->table($this->table)
|
|
|
+ ->where('id', (int) $tokenRow->user_id)
|
|
|
+ ->where('deleted_YN', 'N')
|
|
|
+ ->get()
|
|
|
+ ->getRow();
|
|
|
+
|
|
|
+ if (!$user) {
|
|
|
+ return $this->respondError('회원 정보를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 이전 비밀번호와 동일하면 거부
|
|
|
+ if (password_verify($newPassword, $user->password)) {
|
|
|
+ return $this->respondError('이전과 다른 비밀번호를 입력해 주세요.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+ // 아이디 포함 거부
|
|
|
+ if (stripos($newPassword, $user->username) !== false) {
|
|
|
+ return $this->respondError('비밀번호에 아이디를 포함할 수 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 비밀번호 업데이트
|
|
|
+ $db->table($this->table)->where('id', $user->id)->update([
|
|
|
+ 'password' => password_hash($newPassword, PASSWORD_DEFAULT),
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s'),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 보안상 해당 사용자의 모든 토큰 삭제 (강제 로그아웃)
|
|
|
+ $db->table('user_tokens')->where('user_id', $user->id)->delete();
|
|
|
+
|
|
|
+ return $this->respondSuccess(null, '비밀번호가 변경되었습니다.');
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ log_message('error', 'resetPassword error: ' . $e->getMessage());
|
|
|
+ return $this->respondError('변경 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 아이디 마스킹 — hong****ng 같은 형태
|
|
|
+ */
|
|
|
+ private function maskUsername(string $username): string
|
|
|
+ {
|
|
|
+ $len = strlen($username);
|
|
|
+ if ($len <= 4) {
|
|
|
+ return substr($username, 0, 1) . str_repeat('*', max(0, $len - 1));
|
|
|
+ }
|
|
|
+ if ($len <= 6) {
|
|
|
+ return substr($username, 0, 2) . str_repeat('*', $len - 4) . substr($username, -2);
|
|
|
+ }
|
|
|
+ return substr($username, 0, 4) . str_repeat('*', $len - 6) . substr($username, -2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 핸드폰 숫자 → 010-XXXX-XXXX 형식
|
|
|
+ */
|
|
|
+ private function formatPhone(string $digits): string
|
|
|
+ {
|
|
|
+ if (strlen($digits) === 11) {
|
|
|
+ return substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7);
|
|
|
+ }
|
|
|
+ return substr($digits, 0, 3) . '-' . substr($digits, 3, 3) . '-' . substr($digits, 6);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 회원 로그인
|
|
|
* POST /api/users/login
|