From 6639356f882c625cea4754c316f521572923587d Mon Sep 17 00:00:00 2001 From: hide_d Date: Mon, 8 Nov 2021 04:23:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 로그인 구현 - login_token 테이블 - reqNonce -> loginByToken - sha512(token + nonce) Reviewed-on: https://storage.hided.net/gitea/devsam/core/pulls/196 Co-authored-by: hide_d Co-committed-by: hide_d --- f_config/config.php | 25 +++ f_install/sql/common_schema.sql | 19 ++- hwe/func_converter.php | 25 --- hwe/ts/gateway/login.ts | 137 +++++++++++++++-- j_login.php | 181 ---------------------- oauth_kakao/j_login_oauth.php | 205 +++++++++++++++---------- src/sammo/API/Login/LoginByID.php | 162 ++++++++++++++++++++ src/sammo/API/Login/LoginByToken.php | 150 ++++++++++++++++++ src/sammo/API/Login/ReqNonce.php | 37 +++++ src/sammo/APIHelper.php | 4 +- {hwe => src}/sammo/BaseAPI.php | 0 src/sammo/KakaoUtil.php | 221 +++++++++++++++++++++++++++ src/sammo/Session.php | 26 ++-- 13 files changed, 880 insertions(+), 312 deletions(-) delete mode 100644 j_login.php create mode 100644 src/sammo/API/Login/LoginByID.php create mode 100644 src/sammo/API/Login/LoginByToken.php create mode 100644 src/sammo/API/Login/ReqNonce.php rename {hwe => src}/sammo/BaseAPI.php (100%) create mode 100644 src/sammo/KakaoUtil.php diff --git a/f_config/config.php b/f_config/config.php index 01365cd0..99393641 100644 --- a/f_config/config.php +++ b/f_config/config.php @@ -151,3 +151,28 @@ function logExceptionByCustomHandler(\Throwable $e) throw $e; } set_exception_handler('\\sammo\\logExceptionByCustomHandler'); + +function getAPIExecutorClass($path){ + + static $basePath = __NAMESPACE__.'\\API\\'; + if(is_string($path)){ + } + else if(is_array($path)){ + $path = join('\\', $path); + } + else{ + throw new \InvalidArgumentException("{$path}는 올바른 API 지시자가 아님"); + } + + $classPath = str_replace('/', '\\', $basePath.$path); + + if(class_exists($classPath)){ + return $classPath; + } + throw new \InvalidArgumentException("{$path}는 올바른 API 경로가 아님"); +} + +function buildAPIExecutorClass($type, string $rootPath, array $args):\sammo\BaseAPI{ + $class = getAPIExecutorClass($type); + return new $class($rootPath, $args); +} \ No newline at end of file diff --git a/f_install/sql/common_schema.sql b/f_install/sql/common_schema.sql index 37242eaf..c119febc 100644 --- a/f_install/sql/common_schema.sql +++ b/f_install/sql/common_schema.sql @@ -58,7 +58,7 @@ CREATE TABLE `member_log` ( INDEX `action` (`member_no`, `action_type`, `date`), INDEX `member` (`member_no`, `date`) ) -ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; +ENGINE=Aria DEFAULT CHARSET=utf8mb4; ################### # KV storage @@ -72,4 +72,19 @@ CREATE TABLE if not exists `storage` ( UNIQUE INDEX `key` (`namespace`, `key`) ) COLLATE='utf8mb4_general_ci' -ENGINE=MyISAM \ No newline at end of file +ENGINE=Aria; + +CREATE TABLE `login_token` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL, + `base_token` VARCHAR(20) NOT NULL COLLATE 'utf8mb4_general_ci', + `reg_ip` VARCHAR(40) NOT NULL COLLATE 'utf8mb4_general_ci', + `reg_date` DATETIME NOT NULL, + `expire_date` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `by_token` (`base_token`), + INDEX `by_date` (`user_id`, `expire_date`) +) +COLLATE='utf8mb4_general_ci' +ENGINE=Aria +; \ No newline at end of file diff --git a/hwe/func_converter.php b/hwe/func_converter.php index 4d0f3716..568afa49 100644 --- a/hwe/func_converter.php +++ b/hwe/func_converter.php @@ -357,31 +357,6 @@ function buildNationCommandClass(?string $type, General $generalObj, array $env, return new $class($generalObj, $env, $lastTurn, $arg); } -function getAPIExecutorClass($path){ - - static $basePath = __NAMESPACE__.'\\API\\'; - if(is_string($path)){ - } - else if(is_array($path)){ - $path = join('\\', $path); - } - else{ - throw new \InvalidArgumentException("{$path}는 올바른 API 지시자가 아님"); - } - - $classPath = str_replace('/', '\\', $basePath.$path); - - if(class_exists($classPath)){ - return $classPath; - } - throw new \InvalidArgumentException("{$path}는 올바른 API 경로가 아님"); -} - -function buildAPIExecutorClass($type, string $rootPath, array $args):\sammo\BaseAPI{ - $class = getAPIExecutorClass($type); - return new $class($rootPath, $args); -} - function getWarUnitTriggerClass(string $type){ static $basePath = __NAMESPACE__.'\\WarUnitTrigger\\'; $classPath = ($basePath.$type); diff --git a/hwe/ts/gateway/login.ts b/hwe/ts/gateway/login.ts index 4d9c2223..8112cecc 100644 --- a/hwe/ts/gateway/login.ts +++ b/hwe/ts/gateway/login.ts @@ -1,7 +1,7 @@ import $ from 'jquery'; import 'popper.js'; import Popper from 'popper.js'; -(window as unknown as {Popper:unknown}).Popper = Popper; +(window as unknown as { Popper: unknown }).Popper = Popper; import 'bootstrap'; import { JQValidateForm, NamedRules } from '../util/jqValidateForm'; import axios from 'axios'; @@ -11,9 +11,12 @@ import { unwrap_any } from '../util/unwrap_any'; import { sha512 } from 'js-sha512'; import { unwrap } from '../util/unwrap'; import { InvalidResponse } from '../defs'; +import internal from 'stream'; +import { delay } from '../util/delay'; type LoginResponse = { result: true, + nextToken: [number, string] | undefined, } | { result: false, reqOTP: boolean, @@ -29,6 +32,19 @@ type OTPResponse = { reason: string, } +type AutoLoginNonceResponse = { + result: true, + loginNonce: string, +} | InvalidResponse; + +type AutoLoginResponse = { + result: true, + nextToken: [number, string] | undefined, +} | { + result: false, + silent: boolean, + reason: string, +} declare global { interface Window { getOAuthToken: (mode: string, scope_list: string[]) => void; @@ -41,6 +57,92 @@ declare const kakao_oauth_redirect_uri: string; let oauthMode: string | null = null; +const TOKEN_VERSION = 1; +const LOGIN_TOKEN_KEY = 'sammo_login_token'; +function regNextToken(tokenInfo: [number, string]) { + localStorage.setItem(LOGIN_TOKEN_KEY, JSON.stringify([TOKEN_VERSION, tokenInfo, Date.now()])); +} + +function getToken(): [number, string] | undefined { + const trialToken = localStorage.getItem(LOGIN_TOKEN_KEY); + if (!trialToken) { + return; + } + const tokenItems = JSON.parse(trialToken); + if (tokenItems[0] != TOKEN_VERSION) { + console.log(tokenItems); + resetToken(); + return; + } + const [, token,] = tokenItems; + return token; +} + +function resetToken(){ + localStorage.removeItem(LOGIN_TOKEN_KEY); +} + +async function tryAutoLogin() { + try { + const tokenInfo = getToken(); + if (!tokenInfo) { + return; + } + + const [tokenID, token] = tokenInfo; + + const result = await axios.post('api.php', { + path: ["Login", "ReqNonce"] + }); + + if(!result){ + //api 에러. + return; + } + + if(!result.data.result){ + resetToken(); + return; + } + + const nonce = result.data.loginNonce; + + const hashedToken = sha512(token + nonce); + const _loginResult = await axios.post('api.php', { + path: ["Login", "LoginByToken"], + args: { + 'hashedToken':hashedToken, + 'token_id':tokenID, + } + }); + + if(!_loginResult){ + return; + } + + const loginResult = _loginResult.data; + if(!loginResult.result){ + if(!loginResult.silent){ + alert(loginResult.reason); + } + console.error(loginResult.reason); + return; + } + + if(loginResult.nextToken){ + regNextToken(loginResult.nextToken); + } + window.location.href = "./"; + + } + catch(e){ + console.error(e); + return; + } + + +} + function getOAuthToken(mode: string, scope_list?: string[] | string) { if (mode === undefined) { mode = 'login'; @@ -115,6 +217,9 @@ async function doLoginUsingOAuth() { } if (result.result) { + if (result.nextToken) { + regNextToken(result.nextToken); + } window.location.href = "./"; return; } @@ -156,9 +261,16 @@ function postOAuthResult(mode: string) { window.postOAuthResult = postOAuthResult; -$(function ($) { +$(async function ($) { setAxiosXMLHttpRequest(); + //로그인 먼저 해볼 것 + if(getToken()){ + void tryAutoLogin(); + await delay(100); + } + + type LoginFormType = { username: string, password: string, @@ -194,13 +306,16 @@ $(function ($) { try { const response = await axios({ - url: 'j_login.php', + url: 'api.php', responseType: 'json', method: 'post', - data: convertFormData({ - username: values.username, - password: hash_pw, - }) + data: { + path: ["Login", "LoginByID"], + args: { + username: values.username, + password: hash_pw, + } + } }); result = response.data; } @@ -212,6 +327,9 @@ $(function ($) { if (result.result) { + if (result.nextToken) { + regNextToken(result.nextToken); + } window.location.href = "./"; return; } @@ -268,12 +386,11 @@ $(function ($) { getOAuthToken('change_pw', 'talk_message'); }); - $('#btn_kakao_login').on('click', function(e){ + $('#btn_kakao_login').on('click', function (e) { e.preventDefault(); - getOAuthToken('login', ['account_email','talk_message']); + getOAuthToken('login', ['account_email', 'talk_message']); }) - //TODO: 모바일에서 크기 비례 지도 다시 띄우기? /* if (document.body.clientWidth < 700) { diff --git a/j_login.php b/j_login.php deleted file mode 100644 index 5c5c33ee..00000000 --- a/j_login.php +++ /dev/null @@ -1,181 +0,0 @@ -isLoggedIn()) { - $session->logout(); -} - -$username = mb_strtolower(Util::getPost('username'), 'utf-8'); -$password = Util::getPost('password'); - -if (!$username || !$password) { - Json::die([ - 'result' => false, - 'reqOTP' => false, - 'reason' => '올바르지 않은 입력입니다.' - ]); -} - -function kakaoOAuthCheck(array $userInfo): ?array -{ - - if (!\kakao\KakaoKey::REST_KEY) { - return [false, '카카오 API 앱이 등록되지 않았습니다. 관리자에게 문의해 주세요.']; - } - - $oauthID = $userInfo['oauth_id']; - $oauthInfo = Json::decode($userInfo['oauth_info']) ?? []; - if (!$oauthInfo) { - return [false, 'OAuth 정보가 보관되어 있지 않습니다. 카카오 로그인을 수행해 주세요.']; - } - - $accessToken = $oauthInfo['accessToken'] ?? null; - $refreshToken = $oauthInfo['refreshToken'] ?? null; - $accessTokenValidUntil = $oauthInfo['accessTokenValidUntil'] ?? null; - $refreshTokenValidUntil = $oauthInfo['refreshTokenValidUntil'] ?? null; - $OTPValue = $oauthInfo['OTPValue'] ?? null; - $OTPTrialUntil = $oauthInfo['OTPTrialUntil'] ?? null; - $tokenValidUntil = $userInfo['token_valid_until']; - - if (!$accessToken || !$refreshToken || !$accessTokenValidUntil || !$refreshTokenValidUntil) { - return [false, 'OAuth 정보가 보관되어 있지 않습니다. 카카오 로그인을 수행해 주세요.']; - } - - $now = TimeUtil::now(); - - if ($now > $refreshTokenValidUntil) { - return [false, '로그인 토큰이 만료되었습니다. 카카오 로그인을 수행해 주세요.']; - } - - if ($now > $accessTokenValidUntil) { - $apiHelper = new Kakao_REST_API_Helper($accessToken); - $refreshResult = $apiHelper->refresh_access_token($refreshToken); - if (!$refreshResult) { - return [false, '로그인 토큰 자동 갱신을 실패했습니다. 카카오 로그인을 수행해 주세요.']; - } - - $accessToken = $refreshResult['access_token'] ?? null; - - if (!$accessToken) { - trigger_error("refreshToken 에러 " . Json::encode($refreshResult) . "," . $refreshToken . "," . substr(\kakao\KakaoKey::REST_KEY, 0, 6), E_USER_NOTICE); - return [false, '로그인 토큰 자동 갱신을 실패했습니다. 카카오 로그인을 수행해 주세요.']; - } - $accessTokenValidUntil = TimeUtil::nowAddSeconds($refreshResult['expires_in']); - - $oauthInfo['accessToken'] = $accessToken; - $oauthInfo['accessTokenValidUntil'] = $accessTokenValidUntil; - - $refreshToken = $refreshResult['refresh_token'] ?? null; - if ($refreshToken) { - $refreshTokenValidUntil = TimeUtil::nowAddSeconds($refreshResult['refresh_token_expires_in']); - - $oauthInfo['refreshToken'] = $refreshToken; - $oauthInfo['refresh_token_expires_in'] = $refreshTokenValidUntil; - } - - RootDB::db()->update('member', [ - 'oauth_info' => Json::encode($oauthInfo) - ], 'no=%i', $userInfo['no']); - } - - if ($tokenValidUntil && $now <= $tokenValidUntil) { - return null; - } - - //인증 시스템 가동 - $session = Session::getInstance(); - $session->access_token = $accessToken; - $session->expires = $accessTokenValidUntil; - $session->refresh_token = $refreshToken; - $session->refresh_token_expires = $refreshTokenValidUntil; - - if (!createOTPbyUserNO($userInfo['no'])) { - return [false, '인증 코드를 보내는데 실패했습니다.']; - } - - return [true, '인증 코드를 입력해주세요']; -} - -$userInfo = $RootDB->queryFirstRow( - 'SELECT `no`, `id`, `name`, `grade`, `delete_after`, `acl`, oauth_id, oauth_type, oauth_info, token_valid_until ' . - 'from member where id=%s_username AND ' . - 'pw=sha2(concat(salt, %s_password, salt), 512)', - [ - 'username' => $username, - 'password' => $password - ] -); - -if (!$userInfo) { - Json::die([ - 'result' => false, - 'reqOTP' => false, - 'reason' => '아이디나 비밀번호가 올바르지 않습니다.' - ]); -} - -$canLogin = $RootDB->queryFirstField('SELECT `LOGIN` FROM `system` WHERE `NO` = 1'); -if ($canLogin != 'Y' && $userInfo['grade'] < 5) { - Json::die([ - 'result' => false, - 'reason' => '현재는 로그인이 금지되어있습니다!' - ]); -} - -$nowDate = TimeUtil::now(); -if ($userInfo['delete_after']) { - if ($userInfo['delete_after'] < $nowDate) { - $RootDB->delete('member', 'no=%i', $userInfo['no']); - Json::die([ - 'result' => false, - 'reqOTP' => false, - 'reason' => "기간 만기로 삭제되었습니다. 재 가입을 시도해주세요." - ]); - } else { - Json::die([ - 'result' => false, - 'reqOTP' => false, - 'reason' => "삭제 요청된 계정입니다.[{$userInfo['delete_after']}]" - ]); - } -} - -$RootDB->insert('member_log', [ - 'member_no' => $userInfo['no'], - 'action_type' => 'login', - 'action' => Json::encode([ - 'ip' => Util::get_client_ip(true), - 'type' => 'plain' - ]) -]); - -if ($userInfo['oauth_type'] == 'KAKAO') { - $oauthFailResult = kakaoOAuthCheck($userInfo); - if ($oauthFailResult !== null) { - $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], true, $userInfo['token_valid_until'], Json::decode($userInfo['acl'] ?? '{}')); - [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; - Json::die([ - 'result' => false, - 'reqOTP' => $oauthReqOTP, - 'reason' => $oauthFailReason - ]); - } -} - - -$session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], false, $userInfo['token_valid_until'], Json::decode($userInfo['acl'] ?? '{}')); -Json::die([ - 'result' => true, - 'reqOTP' => false, - 'reason' => '로그인 되었습니다.' -]); diff --git a/oauth_kakao/j_login_oauth.php b/oauth_kakao/j_login_oauth.php index a430f3b5..4d32e003 100644 --- a/oauth_kakao/j_login_oauth.php +++ b/oauth_kakao/j_login_oauth.php @@ -1,25 +1,30 @@ isLoggedIn()){ +if ($session->isLoggedIn()) { $session->logout(); } $canLogin = RootDB::db()->queryFirstField('SELECT `LOGIN` FROM `system` WHERE `NO` = 1'); -if($canLogin != 'Y'){ +if ($canLogin != 'Y') { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'현재는 로그인이 금지되어있습니다!', - 'noRetry'=>true + 'result' => false, + 'reqOTP' => false, + 'reason' => '현재는 로그인이 금지되어있습니다!', + 'noRetry' => true ]); } @@ -32,173 +37,209 @@ $refresh_token_expires = $session->refresh_token_expires; $email = $session->kaccount_email; -if(!$access_token || !$expires){ +if (!$access_token || !$expires) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오로그인이 이루어지지 않았습니다.' + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오로그인이 이루어지지 않았습니다.' ]); } //TODO: join과 login의 동작이 비슷하다. helper class로 묶자. $restAPI = new Kakao_REST_API_Helper($access_token); -if($expires < $now && (!$refresh_token || ($refresh_token_expires < $now))){ +if ($expires < $now && (!$refresh_token || ($refresh_token_expires < $now))) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'로그인 토큰 만료.'.$refresh_token_expires.' 다시 카카오로그인을 수행해주세요.' + 'result' => false, + 'reqOTP' => false, + 'reason' => '로그인 토큰 만료.' . $refresh_token_expires . ' 다시 카카오로그인을 수행해주세요.' ]); } -if($expires < $now){ +if ($expires < $now) { $session->kaccount_email = null; $email = null; $result = $restAPI->refresh_access_token($refresh_token); - if(!isset($refresh_token)){ + if (!isset($refresh_token)) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오 로그인 과정 중 추가 갱신 절차를 실패했습니다' + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오 로그인 과정 중 추가 갱신 절차를 실패했습니다' ]); } $access_token = $result['access_token']; $expires = TimeUtil::nowAddSeconds($result['expires_in']); - if(isset($result['refresh_token'])){ + if (isset($result['refresh_token'])) { $refresh_token = Util::array_get($result['refresh_token']); $refresh_token_expires = TimeUtil::nowAddSeconds($result['refresh_token_expires_in']); } } -if(!$email){ +if (!$email) { $me = $restAPI->meWithEmail(); $me['code'] = Util::array_get($me['code'], 0); - if ($me['code']< 0) { + if ($me['code'] < 0) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오로그인이 정상적으로 수행되지 않았습니다.' + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오로그인이 정상적으로 수행되지 않았습니다.' ]); } - $kakao_account = $me['kakao_account']??null; + $kakao_account = $me['kakao_account'] ?? null; if (!$kakao_account) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오 로그인 정보를 제대로 받아오지 못했습니다.' + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오 로그인 정보를 제대로 받아오지 못했습니다.' ]); } - if(!($kakao_account['has_email']??false)){ + if (!($kakao_account['has_email'] ?? false)) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'이메일 정보 공유를 허가해 주셔야 합니다.', + 'result' => false, + 'reqOTP' => false, + 'reason' => '이메일 정보 공유를 허가해 주셔야 합니다.', ]); } - $validEmail = $kakao_account['is_email_valid']??false; - $verifiedEmail = $kakao_account['is_email_verified']??false; + $validEmail = $kakao_account['is_email_valid'] ?? false; + $verifiedEmail = $kakao_account['is_email_verified'] ?? false; - if(!$validEmail || !$verifiedEmail){ + if (!$validEmail || !$verifiedEmail) { $restAPI->unlink(); Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오 계정 이메일이 아직 인증되지 않았습니다', + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오 계정 이메일이 아직 인증되지 않았습니다', ]); } - + $email = $kakao_account['email']; $session->kaccount_email = $email; } $userInfo = $RootDB->queryFirstRow( - 'SELECT `no`, `id`, `name`, `grade`, `delete_after`, `acl`, oauth_info, token_valid_until from member where email=%s',$email); + 'SELECT `no`, `id`, `name`, `grade`, `delete_after`, `acl`, oauth_info, token_valid_until from member where email=%s', + $email +); -if(!$userInfo){ +if (!$userInfo) { $restAPI->unlink(); $session->access_token = null; Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'카카오로그인에 해당하는 계정이 없습니다. 재 가입을 시도해주세요.', - 'aux'=>$session->tmpx, + 'result' => false, + 'reqOTP' => false, + 'reason' => '카카오로그인에 해당하는 계정이 없습니다. 재 가입을 시도해주세요.', + 'aux' => $session->tmpx, ]); } -if($userInfo['delete_after']){ - if($userInfo['delete_after'] < $now){ +if ($userInfo['delete_after']) { + if ($userInfo['delete_after'] < $now) { $restAPI->unlink(); $session->access_token = null; $RootDB->delete('member', 'no=%i', $userInfo['no']); Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>"기간 만기로 삭제되었습니다. 재 가입을 시도해주세요.", + 'result' => false, + 'reqOTP' => false, + 'reason' => "기간 만기로 삭제되었습니다. 재 가입을 시도해주세요.", ]); - } - else{ + } else { $restAPI->unlink(); $session->access_token = null; Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>"삭제 요청된 계정입니다.[{$userInfo['delete_after']}]" + 'result' => false, + 'reqOTP' => false, + 'reason' => "삭제 요청된 계정입니다.[{$userInfo['delete_after']}]" ]); } - } -$oauthInfo = Json::decode($userInfo['oauth_info'])??[]; +$oauthInfo = Json::decode($userInfo['oauth_info']) ?? []; $oauthInfo['accessToken'] = $access_token; $oauthInfo['accessTokenValidUntil'] = $expires; -if($refresh_token){ +if ($refresh_token) { $oauthInfo['refreshToken'] = $refresh_token; $oauthInfo['refreshTokenValidUntil'] = $refresh_token_expires; } RootDB::db()->update('member', [ - 'oauth_info'=>Json::encode($oauthInfo) + 'oauth_info' => Json::encode($oauthInfo) ], 'no=%i', $userInfo['no']); $tokenValidUntil = $userInfo['token_valid_until']; -if(!$tokenValidUntil || $tokenValidUntil < $now){ - if(!createOTPbyUserNO($userInfo['no'])){ +if (!$tokenValidUntil || $tokenValidUntil < $now) { + if (!createOTPbyUserNO($userInfo['no'])) { Json::die([ - 'result'=>false, - 'reqOTP'=>false, - 'reason'=>'인증 코드를 보내는데 실패했습니다.' - ]); + 'result' => false, + 'reqOTP' => false, + 'reason' => '인증 코드를 보내는데 실패했습니다.' + ]); } - $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], true, $userInfo['token_valid_until'], Json::decode($userInfo['acl']??'{}')); + $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); Json::die([ - 'result'=>false, - 'reqOTP'=>true, - 'reason'=>'인증 코드를 입력해주세요.' + 'result' => false, + 'reqOTP' => true, + 'reason' => '인증 코드를 입력해주세요.' ]); } -$RootDB->insert('member_log',[ - 'member_no'=>$userInfo['no'], - 'action_type'=>'login', - 'action'=>Json::encode([ - 'ip'=>Util::get_client_ip(true), - 'type'=>'kakao' +$RootDB->insert('member_log', [ + 'member_no' => $userInfo['no'], + 'action_type' => 'login', + 'action' => Json::encode([ + 'ip' => Util::get_client_ip(true), + 'type' => 'kakao' ]) ]); -$session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], false, $userInfo['token_valid_until'], Json::decode($userInfo['acl']??'{}')); + +//TODO: 이동하면서 이부분 정리 +$nowDate = TimeUtil::now(); +$RootDB->delete( + 'login_token', + 'user_id = %i AND expire_date < %s', + $userID, + $nowDate +); +$RootDB->query( + 'DELETE invalid FROM login_token AS invalid + JOIN + ( SELECT id + FROM login_token + WHERE user_id = %i + ORDER BY id DESC + LIMIT 8,1 + ) AS valid + ON invalid.id < valid.id WHERE user_id = %i', + $userID, + $userID +); +$token = Util::randomStr(20); +$RootDB->insert('login_token', [ + 'user_id' => $userInfo['no'], + 'base_token' => $token, + 'reg_ip' => Util::get_client_ip(true), + 'reg_date' => $nowDate, + 'expire_date' => TimeUtil::nowAddDays(7) +]); +$tokenID = $RootDB->insertId(); + +$session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], false, $userInfo['token_valid_until'], $tokenID, Json::decode($userInfo['acl'] ?? '{}')); + + + Json::die([ - 'result'=>true, - 'reqOTP'=>false, - 'reason'=>'로그인 되었습니다.' -]); \ No newline at end of file + 'result' => true, + 'reqOTP' => false, + 'nextToken' => [$tokenID, $token], + 'reason' => '로그인 되었습니다.' +]); diff --git a/src/sammo/API/Login/LoginByID.php b/src/sammo/API/Login/LoginByID.php new file mode 100644 index 00000000..3e4ed269 --- /dev/null +++ b/src/sammo/API/Login/LoginByID.php @@ -0,0 +1,162 @@ +args); + $v + ->rule('required', [ + 'username', + 'password' + ]); + + if (!$v->validate()) { + return $v->errorStr(); + } + return null; + } + + public function getRequiredSessionMode(): int + { + return BaseAPI::NO_SESSION; + } + + public function scrubToken(int $userID) + { + $RootDB = RootDB::db(); + $nowDate = TimeUtil::now(); + $RootDB->delete( + 'login_token', + 'user_id = %i AND expire_date < %s', + $userID, + $nowDate + ); + $RootDB->query( + 'DELETE invalid FROM login_token AS invalid + JOIN + ( SELECT id + FROM login_token + WHERE user_id = %i + ORDER BY id DESC + LIMIT 8,1 + ) AS valid + ON invalid.id < valid.id WHERE user_id = %i', + $userID, + $userID + ); + } + + public function addToken(int $userID) + { + $RootDB = RootDB::db(); + $nowDate = TimeUtil::now(); + $token = Util::randomStr(20); + $RootDB->insert('login_token', [ + 'user_id' => $userID, + 'base_token' => $token, + 'reg_ip' => Util::get_client_ip(true), + 'reg_date' => $nowDate, + 'expire_date' => TimeUtil::nowAddDays(7) + ]); + $tokenID = $RootDB->insertId(); + return [$tokenID, $token]; + } + + public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag) + { + $RootDB = RootDB::db(); + if ($session->isLoggedIn()) { + $session->logout(); + } + + $username = $this->args['username']; + $password = $this->args['password']; + + $userInfo = $RootDB->queryFirstRow( + 'SELECT `no`, `id`, `name`, `grade`, `delete_after`, `acl`, oauth_id, oauth_type, oauth_info, token_valid_until ' . + 'from member where id=%s_username AND ' . + 'pw=sha2(concat(salt, %s_password, salt), 512)', + [ + 'username' => $username, + 'password' => $password + ] + ); + + if (!$userInfo) { + return '아이디나 비밀번호가 올바르지 않습니다.'; + } + + $canLogin = $RootDB->queryFirstField('SELECT `LOGIN` FROM `system` WHERE `NO` = 1'); + if ($canLogin != 'Y' && $userInfo['grade'] < 5) { + return '현재는 로그인이 금지되어있습니다!'; + } + + + $nowDate = TimeUtil::now(); + if ($userInfo['delete_after']) { + if ($userInfo['delete_after'] < $nowDate) { + $RootDB->delete('member', 'no=%i', $userInfo['no']); + return [ + 'result' => false, + 'reqOTP' => false, + 'reason' => "기간 만기로 삭제되었습니다. 재 가입을 시도해주세요." + ]; + } else { + return [ + 'result' => false, + 'reqOTP' => false, + 'reason' => "삭제 요청된 계정입니다.[{$userInfo['delete_after']}]" + ]; + } + } + + $RootDB->insert('member_log', [ + 'member_no' => $userInfo['no'], + 'action_type' => 'login', + 'action' => Json::encode([ + 'ip' => Util::get_client_ip(true), + 'type' => 'plain' + ]) + ]); + + if ($userInfo['oauth_type'] == 'KAKAO') { + $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); + if ($oauthFailResult !== null) { + $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); + [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; + return [ + 'result' => false, + 'reqOTP' => $oauthReqOTP, + 'reason' => $oauthFailReason + ]; + } + } + + $this->scrubToken($userInfo['no']); + $nextToken = $this->addToken($userInfo['no']); + + $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], false, $userInfo['token_valid_until'], $nextToken[0], Json::decode($userInfo['acl'] ?? '{}')); + + + return [ + 'result' => true, + 'reqOTP' => false, + 'nextToken' => $nextToken, + 'reason' => '로그인 되었습니다.' + ]; + } +} diff --git a/src/sammo/API/Login/LoginByToken.php b/src/sammo/API/Login/LoginByToken.php new file mode 100644 index 00000000..1bdc646e --- /dev/null +++ b/src/sammo/API/Login/LoginByToken.php @@ -0,0 +1,150 @@ +args); + $v + ->rule('required', [ + 'token_id', + 'hashedToken' + ]); + + if (!$v->validate()) { + return $v->errorStr(); + } + return null; + } + + public function launch(Session $session, ?DateTimeInterface $modifiedSince, ?string $reqEtag) + { + $loginNonce = $session->loginNonce ?? null; + $loginNonceExpired = $session->loginNonceExpired ?? null; + if (!is_string($loginNonce) || !is_string($loginNonceExpired)) { + return '자동 로그인: 절차 오류'; + } + + $RootDB = RootDB::db(); + if ($session->isLoggedIn()) { + $session->logout(); + } + + $token_id = $this->args['token_id']; + + $token_info = $RootDB->queryFirstRow('SELECT * FROM login_token WHERE id = %i', $token_id); + if (!$token_info) { + return [ + 'result' => false, + 'silent' => true, + 'reason' => 'failed' + ]; + } + + $hashedToken = $this->args['hashedToken']; + $tokenAnswer = hash('sha512', "{$token_info['base_token']}{$loginNonce}"); + if (strtolower($hashedToken) != strtolower($tokenAnswer)) { + return [ + 'result' => false, + 'silent' => true, + 'reason' => 'failed' + ]; + } + + $userInfo = $RootDB->queryFirstRow( + 'SELECT `no`, `id`, `name`, `grade`, `delete_after`, `acl`, oauth_id, oauth_type, oauth_info, token_valid_until ' . + 'from member where NO = %i', + $token_info['user_id'] + ); + if(!$userInfo){ + return '자동 로그인: 올바른 계정이 아닙니다.'; + } + + $canLogin = $RootDB->queryFirstField('SELECT `LOGIN` FROM `system` WHERE `NO` = 1'); + if ($canLogin != 'Y' && $userInfo['grade'] < 5) { + return '자동 로그인: 현재는 로그인이 금지되어있습니다!'; + } + + $nowDate = TimeUtil::now(); + if ($userInfo['delete_after']) { + if ($userInfo['delete_after'] < $nowDate) { + $RootDB->delete('member', 'no=%i', $userInfo['no']); + return [ + 'result' => false, + 'silent' => true, + 'reason' => "기간 만기" + ]; + } else { + return [ + 'result' => false, + 'silent' => true, + 'reason' => "삭제 요청된 계정[{$userInfo['delete_after']}]" + ]; + } + } + + $RootDB->insert('member_log', [ + 'member_no' => $userInfo['no'], + 'action_type' => 'login', + 'action' => Json::encode([ + 'ip' => Util::get_client_ip(true), + 'type' => 'auto' + ]) + ]); + + if ($userInfo['oauth_type'] == 'KAKAO') { + $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); + if ($oauthFailResult !== null) { + $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); + [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; + $RootDB->delete( + 'login_token', + 'id = %i', $token_id + ); + return $oauthFailReason; + } + } + + $session->login($userInfo['no'], $userInfo['id'], $userInfo['grade'], false, $userInfo['token_valid_until'], $token_id, Json::decode($userInfo['acl'] ?? '{}')); + $this->scrubToken($userInfo['no']); + + $nextDate = TimeUtil::nowAddDays(2); + if($nextDate < $token_info['expire_date']){ + return [ + 'result' => true, + 'silent' => true, + 'reason' => 'success' + ]; + } + + $RootDB->delete( + 'login_token', + 'id = %i', $token_id + ); + $nextToken = $this->addToken($token_id); + return [ + 'result' => true, + 'silent' => true, + 'nextToken' => $nextToken, + 'reason' => 'success' + ]; + } +} diff --git a/src/sammo/API/Login/ReqNonce.php b/src/sammo/API/Login/ReqNonce.php new file mode 100644 index 00000000..1b4d24bf --- /dev/null +++ b/src/sammo/API/Login/ReqNonce.php @@ -0,0 +1,37 @@ +loginNonce = $loginNonce; + $session->loginNonceExpired = $loginNonceExpired; + return [ + 'result'=>true, + 'loginNonce'=>$loginNonce, + ]; + } + +} \ No newline at end of file diff --git a/src/sammo/APIHelper.php b/src/sammo/APIHelper.php index 01c078e3..60fc0f69 100644 --- a/src/sammo/APIHelper.php +++ b/src/sammo/APIHelper.php @@ -40,7 +40,7 @@ class APIHelper $sessionMode = $obj->getRequiredSessionMode(); if ($sessionMode === BaseAPI::NO_SESSION) { - $session = null; + $session = Session::getInstance();//XXX: NoSession이면 진짜 NoSession이어야..? } else { if ($sessionMode & BaseAPI::REQ_GAME_LOGIN) { $session = Session::requireGameLogin(); @@ -94,7 +94,7 @@ class APIHelper ], $cacheResult === null ? Json::NO_CACHE : 0); } Json::die($result, $cacheResult === null ? Json::NO_CACHE : 0); - } catch (\Exception $e) { + } catch (\Throwable $e) { Json::dieWithReason($e->getMessage()); } catch (mixed $e) { Json::dieWithReason(strval($e)); diff --git a/hwe/sammo/BaseAPI.php b/src/sammo/BaseAPI.php similarity index 100% rename from hwe/sammo/BaseAPI.php rename to src/sammo/BaseAPI.php diff --git a/src/sammo/KakaoUtil.php b/src/sammo/KakaoUtil.php new file mode 100644 index 00000000..f0b9a856 --- /dev/null +++ b/src/sammo/KakaoUtil.php @@ -0,0 +1,221 @@ + 64) { + return '적절하지 않은 길이입니다.'; + } + + $cnt = RootDB::db()->queryFirstField('SELECT count(no) FROM member WHERE `id` = %s LIMIT 1', $username); + if ($cnt != 0) { + return '이미 사용중인 계정명입니다'; + } + return true; + } + + static function checkNicknameDup($nickname) + { + if (!$nickname) { + return '닉네임을 입력해주세요'; + } + + $length = mb_strwidth($nickname, 'utf-8'); + if ($length < 1 || $length > 18) { + return '적절하지 않은 길이입니다.'; + } + + $cnt = RootDB::db()->queryFirstField('SELECT count(no) FROM member WHERE `name` = %s LIMIT 1', $nickname); + if ($cnt != 0) { + return '이미 사용중인 닉네임입니다'; + } + return true; + } + + + static function checkEmailDup($email) + { + if (!$email) { + return '이메일을 입력해주세요'; + } + + $length = strlen($email); + if ($length < 1 || $length > 64) { + return '적절하지 않은 길이입니다.'; + } + + $userInfo = RootDB::db()->queryFirstField('SELECT `no`, `delete_after` FROM member WHERE `email` = %s LIMIT 1', $email); + if ($userInfo) { + if (!$userInfo['delete_after']) { + return '이미 사용중인 이메일입니다. 관리자에게 문의해주세요.'; + } + + if ($userInfo['delete_after'] >= $userInfo) { + return "삭제 요청된 계정입니다.[{$userInfo['delete_after']}]"; + } + + //$userInfo['delete_after'] < $userInfo + RootDB::db()->delete('member', 'no=%i', $userInfo['no']); + } + return true; + } + + static function createOTPbyUserNO(int $userNo): bool + { + $userInfo = RootDB::db()->queryFirstRow('SELECT oauth_info FROM member WHERE no=%i', $userNo); + if (!$userInfo) { + return false; + } + + $oauthInfo = Json::decode($userInfo['oauth_info']); + if (!$oauthInfo) { + return false; + } + + $accessToken = $oauthInfo['accessToken']; + $OTPValue = $oauthInfo['OTPValue'] ?? null; + $OTPTrialUntil = $oauthInfo['OTPTrialUntil'] ?? null; + + $now = TimeUtil::now(); + + + if ($OTPTrialUntil && $OTPValue && $OTPTrialUntil > $now) { + return true; + } + + [$OTPValue, $OTPTrialUntil] = static::createOTP($accessToken); + + if (!$OTPValue) { + return false; + } + + $oauthInfo['OTPValue'] = $OTPValue; + $oauthInfo['OTPTrialUntil'] = $OTPTrialUntil; + $oauthInfo['OTPTrialCount'] = 3; + + RootDB::db()->update('member', [ + 'oauth_info' => Json::encode($oauthInfo) + ], 'no=%i', $userNo); + + return true; + } + + static function createOTP(string $accessToken): ?array + { + $restAPI = new Kakao_REST_API_Helper($accessToken); + + $OTPValue = Util::randRangeInt(1000, 9999); + $OTPTrialUntil = TimeUtil::nowAddSeconds(180); + + $sendResult = $restAPI->talk_to_me_default([ + "object_type" => "text", + "text" => "인증 코드는 $OTPValue 입니다. $OTPTrialUntil 이내에 입력해주세요.", + "link" => [ + "web_url" => ServConfig::getServerBasepath(), + "mobile_web_url" => ServConfig::getServerBasepath() + ], + "button_title" => "로그인 페이지 열기" + ]); + $sendResult['code'] = Util::array_get($sendResult['code'], 0); + if ($sendResult['code'] < 0) { + return null; + } + + return [$OTPValue, $OTPTrialUntil]; + } + + static function kakaoOAuthCheck(array $userInfo): ?array + { + + if (!\kakao\KakaoKey::REST_KEY) { + return [false, '카카오 API 앱이 등록되지 않았습니다. 관리자에게 문의해 주세요.']; + } + + $oauthID = $userInfo['oauth_id']; + $oauthInfo = Json::decode($userInfo['oauth_info']) ?? []; + if (!$oauthInfo) { + return [false, 'OAuth 정보가 보관되어 있지 않습니다. 카카오 로그인을 수행해 주세요.']; + } + + $accessToken = $oauthInfo['accessToken'] ?? null; + $refreshToken = $oauthInfo['refreshToken'] ?? null; + $accessTokenValidUntil = $oauthInfo['accessTokenValidUntil'] ?? null; + $refreshTokenValidUntil = $oauthInfo['refreshTokenValidUntil'] ?? null; + $OTPValue = $oauthInfo['OTPValue'] ?? null; + $OTPTrialUntil = $oauthInfo['OTPTrialUntil'] ?? null; + $tokenValidUntil = $userInfo['token_valid_until']; + + if (!$accessToken || !$refreshToken || !$accessTokenValidUntil || !$refreshTokenValidUntil) { + return [false, 'OAuth 정보가 보관되어 있지 않습니다. 카카오 로그인을 수행해 주세요.']; + } + + $now = TimeUtil::now(); + + if ($now > $refreshTokenValidUntil) { + return [false, '로그인 토큰이 만료되었습니다. 카카오 로그인을 수행해 주세요.']; + } + + if ($now > $accessTokenValidUntil) { + $apiHelper = new Kakao_REST_API_Helper($accessToken); + $refreshResult = $apiHelper->refresh_access_token($refreshToken); + if (!$refreshResult) { + return [false, '로그인 토큰 자동 갱신을 실패했습니다. 카카오 로그인을 수행해 주세요.']; + } + + $accessToken = $refreshResult['access_token'] ?? null; + + if (!$accessToken) { + trigger_error("refreshToken 에러 " . Json::encode($refreshResult) . "," . $refreshToken . "," . substr(\kakao\KakaoKey::REST_KEY, 0, 6), E_USER_NOTICE); + return [false, '로그인 토큰 자동 갱신을 실패했습니다. 카카오 로그인을 수행해 주세요.']; + } + $accessTokenValidUntil = TimeUtil::nowAddSeconds($refreshResult['expires_in']); + + $oauthInfo['accessToken'] = $accessToken; + $oauthInfo['accessTokenValidUntil'] = $accessTokenValidUntil; + + $refreshToken = $refreshResult['refresh_token'] ?? null; + if ($refreshToken) { + $refreshTokenValidUntil = TimeUtil::nowAddSeconds($refreshResult['refresh_token_expires_in']); + + $oauthInfo['refreshToken'] = $refreshToken; + $oauthInfo['refresh_token_expires_in'] = $refreshTokenValidUntil; + } + + RootDB::db()->update('member', [ + 'oauth_info' => Json::encode($oauthInfo) + ], 'no=%i', $userInfo['no']); + } + + if ($tokenValidUntil && $now <= $tokenValidUntil) { + return null; + } + + //인증 시스템 가동 + $session = Session::getInstance(); + $session->access_token = $accessToken; + $session->expires = $accessTokenValidUntil; + $session->refresh_token = $refreshToken; + $session->refresh_token_expires = $refreshTokenValidUntil; + + if (!createOTPbyUserNO($userInfo['no'])) { + return [false, '인증 코드를 보내는데 실패했습니다.']; + } + + return [true, '인증 코드를 입력해주세요']; + } +} diff --git a/src/sammo/Session.php b/src/sammo/Session.php index a9b198dc..f3dbe49f 100644 --- a/src/sammo/Session.php +++ b/src/sammo/Session.php @@ -10,8 +10,9 @@ namespace sammo; * @property string $ip IP * @property bool $reqOTP 인증 코드 필요 * @property array $acl 권한 + * @property int|null $tokenID 토큰ID * @property string $tokenValidUntil 로그인 토큰 길이 - * + * * @property int $generalID 장수 번호 (게임 로그인 필요) * @property string $generalName 장수 이름 (게임 로그인 필요) */ @@ -35,7 +36,7 @@ class Session const GAME_KEY_GENERAL_ID = '_g_no'; const GAME_KEY_GENERAL_NAME = '_g_name'; const GAME_KEY_EXPECTED_DEADTIME = '_g_deadtime'; - + private $writeClosed = false; private $sessionID = null; @@ -170,7 +171,7 @@ class Session return Util::array_get($_SESSION[$name]); } - public function login(int $userID, string $userName, int $grade, bool $reqOTP, ?string $tokenValidUntil, array $acl): Session + public function login(int $userID, string $userName, int $grade, bool $reqOTP, ?string $tokenValidUntil, ?int $tokenID, array $acl): Session { $this->set('userID', $userID); $this->set('userName', $userName); @@ -180,6 +181,7 @@ class Session $this->set('acl', $acl); $this->set('reqOTP', $reqOTP); $this->set('tokenValidUntil', $tokenValidUntil); + $this->set('tokenID', $tokenID); return $this; } @@ -197,7 +199,11 @@ class Session if (class_exists('\\sammo\\UniqueConst')) { $this->logoutGame(); } - + + if($this->tokenID??null){ + RootDB::db()->delete('login_token', 'id = %i', $this->tokenID); + } + $this->set('userID', null); $this->set('userName', null); $this->set('userGrade', null); @@ -261,7 +267,7 @@ class Session } return $this; } - + $turnterm = $gameStor->turnterm; $isUnited = $gameStor->isunited != 0; @@ -319,7 +325,7 @@ class Session } else { $obj = self::getInstance(); } - + return $obj->userGrade; } @@ -335,12 +341,12 @@ class Session } else { $obj = self::getInstance(); } - + return $obj->userID; } /** - * + * */ public static function getGeneralID(bool $requireLogin = false, string $exitPath = '..') { @@ -349,7 +355,7 @@ class Session } else { $obj = self::getInstance(); } - + return $obj->generalID??0; } @@ -367,7 +373,7 @@ class Session return false; } } - + if ($this->userID) { return true; } else {