UsersController.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. <?php
  2. namespace App\Controllers\Api;
  3. use CodeIgniter\HTTP\ResponseInterface;
  4. class UsersController extends BaseApiController
  5. {
  6. protected $format = 'json';
  7. protected $table = 'users';
  8. private const ALLOWED_AREAS = ['남해', '동해', '민물', '서해', '제주'];
  9. /**
  10. * 아이디 찾기 — 이름 + 핸드폰으로 조회 → 마스킹된 아이디 반환
  11. * POST /api/users/find-id
  12. */
  13. public function findId()
  14. {
  15. try {
  16. $payload = $this->request->getJSON(true);
  17. $name = trim((string) ($payload['name'] ?? ''));
  18. $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
  19. if ($name === '') return $this->respondError('이름을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  20. if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
  21. return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  22. }
  23. $phoneFormatted = $this->formatPhone($phoneDigits);
  24. $user = $this->getDB()->table($this->table)
  25. ->where('name', $name)
  26. ->where('phone', $phoneFormatted)
  27. ->where('deleted_YN', 'N')
  28. ->get()
  29. ->getRow();
  30. if (!$user) {
  31. return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  32. }
  33. return $this->respondSuccess([
  34. 'username_masked' => $this->maskUsername($user->username),
  35. 'signup_type' => $user->signup_type,
  36. 'created_at' => $user->created_at,
  37. ], '아이디 조회 성공');
  38. } catch (\Exception $e) {
  39. log_message('error', 'findId error: ' . $e->getMessage());
  40. return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  41. }
  42. }
  43. /**
  44. * 비밀번호 재설정 — 본인 확인 (아이디+이름+핸드폰)
  45. * POST /api/users/verify-for-reset
  46. * 일치하면 10분 짜리 임시 토큰 발급
  47. */
  48. public function verifyForReset()
  49. {
  50. try {
  51. $payload = $this->request->getJSON(true);
  52. $username = trim((string) ($payload['username'] ?? ''));
  53. $name = trim((string) ($payload['name'] ?? ''));
  54. $phoneDigits = preg_replace('/[^0-9]/', '', (string) ($payload['phone'] ?? ''));
  55. if ($username === '' || $name === '') {
  56. return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  57. }
  58. if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
  59. return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  60. }
  61. $phoneFormatted = $this->formatPhone($phoneDigits);
  62. $db = $this->getDB();
  63. $user = $db->table($this->table)
  64. ->where('username', $username)
  65. ->where('name', $name)
  66. ->where('phone', $phoneFormatted)
  67. ->where('deleted_YN', 'N')
  68. ->get()
  69. ->getRow();
  70. if (!$user) {
  71. return $this->respondError('일치하는 회원 정보가 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  72. }
  73. // 소셜 가입자는 비밀번호 재설정 불가
  74. if ($user->signup_type !== 'local') {
  75. return $this->respondError(
  76. '소셜 가입 회원입니다. (' . $user->signup_type . ')',
  77. ResponseInterface::HTTP_BAD_REQUEST
  78. );
  79. }
  80. // 10분짜리 임시 토큰 발급
  81. $token = bin2hex(random_bytes(32));
  82. $expiresAt = date('Y-m-d H:i:s', strtotime('+10 minutes'));
  83. $db->table('user_tokens')->insert([
  84. 'user_id' => $user->id,
  85. 'token' => $token,
  86. 'expires_at' => $expiresAt,
  87. 'created_at' => date('Y-m-d H:i:s'),
  88. ]);
  89. return $this->respondSuccess([
  90. 'reset_token' => $token,
  91. 'expires_at' => $expiresAt,
  92. ], '본인 확인 완료');
  93. } catch (\Exception $e) {
  94. log_message('error', 'verifyForReset error: ' . $e->getMessage());
  95. return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  96. }
  97. }
  98. /**
  99. * 비밀번호 재설정 실제 적용
  100. * POST /api/users/reset-password
  101. */
  102. public function resetPassword()
  103. {
  104. try {
  105. $payload = $this->request->getJSON(true);
  106. $resetToken = trim((string) ($payload['reset_token'] ?? ''));
  107. $newPassword = (string) ($payload['new_password'] ?? '');
  108. if ($resetToken === '' || $newPassword === '') {
  109. return $this->respondError('정보를 모두 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  110. }
  111. if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $newPassword)) {
  112. return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  113. }
  114. $db = $this->getDB();
  115. $tokenRow = $db->table('user_tokens')
  116. ->where('token', $resetToken)
  117. ->where('expires_at >', date('Y-m-d H:i:s'))
  118. ->get()
  119. ->getRow();
  120. if (!$tokenRow) {
  121. return $this->respondError('유효하지 않거나 만료된 인증입니다.', ResponseInterface::HTTP_UNAUTHORIZED);
  122. }
  123. $user = $db->table($this->table)
  124. ->where('id', (int) $tokenRow->user_id)
  125. ->where('deleted_YN', 'N')
  126. ->get()
  127. ->getRow();
  128. if (!$user) {
  129. return $this->respondError('회원 정보를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  130. }
  131. // 이전 비밀번호와 동일하면 거부
  132. if (password_verify($newPassword, $user->password)) {
  133. return $this->respondError('이전과 다른 비밀번호를 입력해 주세요.', ResponseInterface::HTTP_BAD_REQUEST);
  134. }
  135. // 아이디 포함 거부
  136. if (stripos($newPassword, $user->username) !== false) {
  137. return $this->respondError('비밀번호에 아이디를 포함할 수 없습니다.', ResponseInterface::HTTP_BAD_REQUEST);
  138. }
  139. // 비밀번호 업데이트
  140. $db->table($this->table)->where('id', $user->id)->update([
  141. 'password' => password_hash($newPassword, PASSWORD_DEFAULT),
  142. 'updated_at' => date('Y-m-d H:i:s'),
  143. ]);
  144. // 보안상 해당 사용자의 모든 토큰 삭제 (강제 로그아웃)
  145. $db->table('user_tokens')->where('user_id', $user->id)->delete();
  146. return $this->respondSuccess(null, '비밀번호가 변경되었습니다.');
  147. } catch (\Exception $e) {
  148. log_message('error', 'resetPassword error: ' . $e->getMessage());
  149. return $this->respondError('변경 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  150. }
  151. }
  152. /**
  153. * 아이디 마스킹 — hong****ng 같은 형태
  154. */
  155. private function maskUsername(string $username): string
  156. {
  157. $len = strlen($username);
  158. if ($len <= 4) {
  159. return substr($username, 0, 1) . str_repeat('*', max(0, $len - 1));
  160. }
  161. if ($len <= 6) {
  162. return substr($username, 0, 2) . str_repeat('*', $len - 4) . substr($username, -2);
  163. }
  164. return substr($username, 0, 4) . str_repeat('*', $len - 6) . substr($username, -2);
  165. }
  166. /**
  167. * 핸드폰 숫자 → 010-XXXX-XXXX 형식
  168. */
  169. private function formatPhone(string $digits): string
  170. {
  171. if (strlen($digits) === 11) {
  172. return substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7);
  173. }
  174. return substr($digits, 0, 3) . '-' . substr($digits, 3, 3) . '-' . substr($digits, 6);
  175. }
  176. /**
  177. * 회원 로그인
  178. * POST /api/users/login
  179. */
  180. public function login()
  181. {
  182. try {
  183. $payload = $this->request->getJSON(true);
  184. if (!is_array($payload)) $payload = [];
  185. $username = trim((string) ($payload['username'] ?? ''));
  186. $password = (string) ($payload['password'] ?? '');
  187. $autoLogin = !empty($payload['auto_login']);
  188. if ($username === '' || $password === '') {
  189. return $this->respondError('아이디와 비밀번호를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  190. }
  191. $db = $this->getDB();
  192. $user = $db->table($this->table)
  193. ->where('username', $username)
  194. ->where('deleted_YN', 'N')
  195. ->get()
  196. ->getRow();
  197. if (!$user) {
  198. return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
  199. }
  200. // 계정 상태 확인
  201. if ($user->status === 'inactive') {
  202. return $this->respondError('비활성화된 계정입니다.', ResponseInterface::HTTP_FORBIDDEN);
  203. }
  204. if ($user->status === 'suspended') {
  205. return $this->respondError('정지된 계정입니다. 고객센터에 문의하세요.', ResponseInterface::HTTP_FORBIDDEN);
  206. }
  207. // 비밀번호 검증
  208. if (!password_verify($password, $user->password)) {
  209. return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
  210. }
  211. // last_login_at 업데이트
  212. $now = date('Y-m-d H:i:s');
  213. $db->table($this->table)->where('id', $user->id)->update([
  214. 'last_login_at' => $now,
  215. ]);
  216. // 토큰 발급 + 저장 (자동로그인: 30일 / 일반: 24시간)
  217. $token = bin2hex(random_bytes(32));
  218. $expiresAt = $autoLogin
  219. ? date('Y-m-d H:i:s', strtotime('+30 days'))
  220. : date('Y-m-d H:i:s', strtotime('+24 hours'));
  221. $db->table('user_tokens')->insert([
  222. 'user_id' => $user->id,
  223. 'token' => $token,
  224. 'expires_at' => $expiresAt,
  225. 'created_at' => $now,
  226. ]);
  227. return $this->respondSuccess([
  228. 'token' => $token,
  229. 'expires_at' => $expiresAt,
  230. 'user' => [
  231. 'id' => (int) $user->id,
  232. 'username' => $user->username,
  233. 'nickname' => $user->nickname,
  234. 'name' => $user->name,
  235. 'phone' => $user->phone,
  236. 'prefer_area' => $user->prefer_area,
  237. 'profile_image'=> $user->profile_image,
  238. ],
  239. ], '로그인 성공');
  240. } catch (\Exception $e) {
  241. log_message('error', 'login error: ' . $e->getMessage());
  242. return $this->respondError('로그인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  243. }
  244. }
  245. /**
  246. * 회원 로그아웃
  247. * POST /api/users/logout
  248. */
  249. public function logout()
  250. {
  251. try {
  252. $authHeader = $this->getAuthHeader();
  253. if (!empty($authHeader)) {
  254. $token = str_replace('Bearer ', '', $authHeader);
  255. if (!empty($token)) {
  256. $this->getDB()->table('user_tokens')->where('token', $token)->delete();
  257. }
  258. }
  259. return $this->respondSuccess(null, '로그아웃 성공');
  260. } catch (\Exception $e) {
  261. log_message('error', 'logout error: ' . $e->getMessage());
  262. return $this->respondError('로그아웃 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  263. }
  264. }
  265. /**
  266. * 아이디 중복 확인
  267. * POST /api/users/check-username
  268. */
  269. public function checkUsername()
  270. {
  271. try {
  272. $payload = $this->request->getJSON(true);
  273. $username = trim((string) ($payload['username'] ?? ''));
  274. if ($username === '') {
  275. return $this->respondError('아이디를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  276. }
  277. if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
  278. return $this->respondError('영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  279. }
  280. $exists = $this->getDB()->table($this->table)
  281. ->where('username', $username)
  282. ->where('deleted_YN', 'N')
  283. ->countAllResults();
  284. if ($exists > 0) {
  285. return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  286. }
  287. return $this->respondSuccess(null, '사용 가능한 아이디입니다.');
  288. } catch (\Exception $e) {
  289. log_message('error', 'checkUsername error: ' . $e->getMessage());
  290. return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  291. }
  292. }
  293. /**
  294. * 닉네임 중복 확인
  295. * POST /api/users/check-nickname
  296. */
  297. public function checkNickname()
  298. {
  299. try {
  300. $payload = $this->request->getJSON(true);
  301. $nickname = trim((string) ($payload['nickname'] ?? ''));
  302. if ($nickname === '') {
  303. return $this->respondError('닉네임을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  304. }
  305. if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
  306. return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  307. }
  308. $exists = $this->getDB()->table($this->table)
  309. ->where('nickname', $nickname)
  310. ->where('deleted_YN', 'N')
  311. ->countAllResults();
  312. if ($exists > 0) {
  313. return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  314. }
  315. return $this->respondSuccess(null, '사용 가능한 닉네임입니다.');
  316. } catch (\Exception $e) {
  317. log_message('error', 'checkNickname error: ' . $e->getMessage());
  318. return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  319. }
  320. }
  321. /**
  322. * 회원가입
  323. * POST /api/users/signup
  324. */
  325. public function signup()
  326. {
  327. try {
  328. $payload = $this->request->getJSON(true);
  329. if (!is_array($payload)) $payload = [];
  330. $username = trim((string) ($payload['username'] ?? ''));
  331. $password = (string) ($payload['password'] ?? '');
  332. $name = trim((string) ($payload['name'] ?? ''));
  333. $phone = trim((string) ($payload['phone'] ?? ''));
  334. $nickname = trim((string) ($payload['nickname'] ?? ''));
  335. $preferArea = trim((string) ($payload['prefer_area'] ?? ''));
  336. $marketingAgree = (($payload['marketing_agree_YN'] ?? 'N') === 'Y') ? 'Y' : 'N';
  337. // 검증
  338. if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
  339. return $this->respondError('아이디는 영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  340. }
  341. if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $password)) {
  342. return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 조합 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  343. }
  344. if ($name === '' || mb_strlen($name) > 50) {
  345. return $this->respondError('이름을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  346. }
  347. // 핸드폰 정규화: 숫자만 추출 후 0XX-XXXX-XXXX 형식으로
  348. // 010 / 011 / 016 / 017 / 018 / 019 모두 허용 (10~11자리)
  349. $phoneDigits = preg_replace('/[^0-9]/', '', $phone);
  350. if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
  351. return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  352. }
  353. // 11자리: 3-4-4 / 10자리: 3-3-4
  354. if (strlen($phoneDigits) === 11) {
  355. $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 4) . '-' . substr($phoneDigits, 7);
  356. } else {
  357. $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6);
  358. }
  359. if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
  360. return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  361. }
  362. // 선호지역 검증
  363. $preferAreaNormalized = null;
  364. if ($preferArea !== '') {
  365. $areas = array_filter(array_map('trim', explode(',', $preferArea)));
  366. foreach ($areas as $a) {
  367. if (!in_array($a, self::ALLOWED_AREAS, true)) {
  368. return $this->respondError('잘못된 선호지역입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  369. }
  370. }
  371. $preferAreaNormalized = implode(',', array_values(array_unique($areas)));
  372. }
  373. $db = $this->getDB();
  374. // 중복 체크
  375. $userExists = $db->table($this->table)
  376. ->where('username', $username)
  377. ->where('deleted_YN', 'N')
  378. ->countAllResults();
  379. if ($userExists > 0) {
  380. return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  381. }
  382. $nickExists = $db->table($this->table)
  383. ->where('nickname', $nickname)
  384. ->where('deleted_YN', 'N')
  385. ->countAllResults();
  386. if ($nickExists > 0) {
  387. return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  388. }
  389. // INSERT
  390. $db->table($this->table)->insert([
  391. 'username' => $username,
  392. 'password' => password_hash($password, PASSWORD_DEFAULT),
  393. 'nickname' => $nickname,
  394. 'name' => $name,
  395. 'phone' => $phoneFormatted,
  396. 'prefer_area' => $preferAreaNormalized,
  397. 'signup_type' => 'local',
  398. 'marketing_agree_YN' => $marketingAgree,
  399. 'status' => 'active',
  400. 'deleted_YN' => 'N',
  401. 'created_at' => date('Y-m-d H:i:s'),
  402. ]);
  403. $newId = $db->insertID();
  404. return $this->respondSuccess(
  405. ['id' => $newId, 'username' => $username, 'nickname' => $nickname],
  406. '회원가입이 완료되었습니다.',
  407. ResponseInterface::HTTP_CREATED
  408. );
  409. } catch (\Exception $e) {
  410. log_message('error', 'signup error: ' . $e->getMessage());
  411. return $this->respondError('회원가입 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  412. }
  413. }
  414. }