この記事は「世界最悪のログイン処理コード」をセキュアに実装してみるの続きです。
データベースの設定
以下にデータベースで行っている事のうち、重要な部分をピックアップして紹介します。
テーブル定義
テーブル定義は一般的なアプリケーションと同じで非常に簡単です。データを保存するテーブルは、ユーザーテーブルとフレンド関連テーブルがあれば十分でしょう。以下のように定義しました。
-- ユーザーテーブル
CREATE TABLE users(
user_id INT PRIMARY KEY AUTO_INCREMENT -- ユーザーID
, login_name VARCHAR (32) NOT NULL -- ロクイン名
, profile VARCHAR (200) -- プロフィール
, email VARCHAR (40) NOT NULL -- メールアドレス
, profile_open ENUM('none', 'friends', 'members', 'open') NOT NULL DEFAULT 'none' -- プロフィール公開範囲
, is_admin BOOLEAN NOT NULL DEFAULT FALSE, -- 管理者かどうか
INDEX(login_name)
);
-- ユーザー・フレンドユーザー関連テーブル
CREATE TABLE users_friend_users(
user_id INT NOT NULL -- ユーザーID
, friend_user_id INT NOT NULL -- フレンドのユーザーID
, PRIMARY KEY (user_id, friend_user_id)
, FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
, FOREIGN KEY (friend_user_id) REFERENCES users(user_id) ON DELETE CASCADE
, INDEX (user_id)
, INDEX (friend_user_id)
);
ごく一般的なテーブル定義ですが、user.login_name
はシステムテーブルのカラムである mysql.user.User
と紐づいています。これまで述べてきた通り、各ユーザーはそれぞれの MySQL アカウントでログインするためです。本来なら外部キーを定義したい所なのですが、mysql.user.User
はユニークでは無いためやりませんでした。
なお、SDCBAR を実現するために内部的に利用する以下2つのテーブルを追加で定義しました。
-- ロック管理テーブル
CREATE TABLE lock_ctl(lock_id VARCHAR (12) PRIMARY KEY);
-- ロックテーブルのデータ投入
INSERT INTO lock_ctl VALUES('create-user');
-- 権限管理テーブル
CREATE TABLE auth_map(
auth_map_id INT PRIMARY KEY AUTO_INCREMENT
, role VARCHAR(20) NOT NULL -- ロール
, auth_target VARCHAR(40) NOT NULL -- 対象名(テーブル名、プロシージャー名等)
, auth_type VARCHAR(20) NOT NULL -- 権限名(SELECT, INSERT, PROCEDURE, FUNCTION 等)
);
lock_ctl
テーブルはロックを取得するために利用します。ロック種別ごとにあらかじめ1行ずつ用意しておくのですが、このアプリケーションではユーザー作成時のみロックが必要なので1行だけ作成しました。ロックの利用箇所については後述します。
auth_map
はそれぞれのユーザーごとに認可を指定するためのテーブルです。このテーブルを参照しながらストアドプロシージャー内で GRANT
文相当の事を実行します。こちらも詳細は後述します。
ユーザー登録
ユーザー登録機能は最難関です。普通なら users
テーブルに新しいレコードをインサートするだけですが、SDCBAR ではデータベースアカウントと同期しなければなりません。そのためストアドプロシージャーを利用します。
-- ユーザーの追加
DELIMITER //
CREATE PROCEDURE add_user(
_login_name VARCHAR(12), -- ログイン名
_password VARCHAR(20), -- パスワード
_profile VARCHAR(200), -- プロフィール
_email VARCHAR(40) -- メールアドレス
)
BEGIN
DECLARE _lock INT;
DECLARE _exists VARCHAR(40);
-- システムテーブルを操作するために、ロックを確保する
SET _lock = (SELECT 1 FROM lock_ctl WHERE lock_id = 'create-user' FOR UPDATE);
-- 既存ユーザー確認処理
SET _exists = (SELECT user FROM mysql.user WHERE user=_login_name AND host='%');
IF _exists IS NOT NULL OR _login_name = 'anonymous' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'User is already exists.', MYSQL_ERRNO = 1001;
ELSE
-- 関連するテーブルに行を追加
CALL add_user_core(_login_name, _password, _profile, _email);
-- 権限の設定
CALL setup_role('member', _login_name);
-- システムテーブルの変更を反映させる
FLUSH PRIVILEGES;
END IF;
END;
//
DELIMITER ;
最初に行っているロックの確保は、この後に mysql.user
テーブルや mysql.tables_priv
テーブルを操作するからです。システムテーブルであるこれらはストレージエンジンが MyISAM であるため、アトミックな操作が必要な時にはロックが必要です。さらに実際の権限情報に反映させるためには操作完了後に FLUSH PRIVILEGES
が必要です。
ロックしたら今度は既存ユーザーかどうかをチェックしています。チェック完了後は add_user_core()
でレコード挿入を行います。
-- ユーザー追加時の行追加
DELIMITER //
CREATE PROCEDURE add_user_core(
_login_name VARCHAR(12), -- ログイン名
_password VARCHAR(20), -- パスワード
_profile VARCHAR(200), -- プロフィール
_email VARCHAR(40) -- メールアドレス
)
BEGIN
-- users テーブルに新規ユーザーを追加
INSERT INTO users (login_name, profile, email, profile_open, is_admin)
VALUES(_login_name, _profile, _email, 'none', FALSE);
-- データベースアカウントに紐づけるため、システムテーブルに新規ユーザーを追加
-- ローカルアクセス用 (localhost) と、ネットワーク経由用 ('%') の2つのアカウントを登録している
INSERT INTO mysql.user (User, Host, authentication_string, ssl_cipher, x509_issuer, x509_subject) VALUES
(_login_name, '%', password(_password), '', ''),
(_login_name, 'localhost', password(_password), '', '');
-- この段階では新しいユーザー情報は反映されない。FLUSH PRIVILEGES を実行して初めて反映される
END;
//
DELIMITER ;
users
テーブルに行挿入すると同時に、mysql.user
テーブルにも行挿入をしている所がポイントです。これは本来 CREATE USER
文で行うべき処理なのですが、CREATE USER
文ではアカウント名を変数として扱いにくいのでこのようにしました。なお、「扱いにくい」という表現を使っているのは、扱えないわけではないからです。PREPARE
文で CREATE USER
文を組み立てれば可能です。しかしそうなると文字列として SQL を構築しなければならないため、今回は避ける事にしました。
setup_role()
は新しいユーザーに適切な権限を与えるためのストアドプロシージャーです。これを解説する前に、前述の auth_map
テーブルについて解説します。このテーブルはロールに応じてどのような権限を与えるかを定義するテーブルで、あらかじめ以下のようにデータ投入されています。
-- 権限テーブルのデータ投入
INSERT INTO auth_map (role, auth_target, auth_type) VALUES
('admin', 'users', 'SELECT'),
('admin', 'users_view', 'SELECT'),
('admin', 'users_friend_users', 'SELECT'),
('admin', 'add_user', 'PROCEDURE'),
('admin', 'update_user', 'PROCEDURE'),
('admin', 'update_me', 'PROCEDURE'),
('admin', 'remove_user', 'PROCEDURE'),
('admin', 'add_admin', 'PROCEDURE'),
('admin', 'remove_admin', 'PROCEDURE'),
('admin', 'add_friend', 'PROCEDURE'),
('admin', 'remove_friend', 'PROCEDURE'),
('admin', 'my_info', 'PROCEDURE'),
('admin', 'user_id', 'FUNCTION'),
('admin', 'my_login_name', 'FUNCTION'),
('admin', 'my_role', 'FUNCTION'),
('member', 'users_view', 'SELECT'),
('member', 'users_friend_users', 'SELECT'),
('member', 'update_me', 'PROCEDURE'),
('member', 'remove_me', 'PROCEDURE'),
('member', 'add_friend', 'PROCEDURE'),
('member', 'remove_friend', 'PROCEDURE'),
('member', 'my_info', 'PROCEDURE'),
('member', 'user_id', 'FUNCTION'),
('member', 'my_login_name', 'FUNCTION'),
('member', 'my_role', 'FUNCTION'),
('anonymous', 'users_view', 'SELECT'),
('anonymous', 'add_user', 'PROCEDURE'),
('anonymous', 'user_id', 'FUNCTION'),
('anonymous', 'my_login_name', 'FUNCTION'),
('anonymous', 'my_role', 'FUNCTION');
まだ解説していない色々なストアドプロシージャーやストアドファンクションがありますが、このようにロールに応じて利用可能かどうか、またテーブルの場合は SELECT 可能かどうか等を定義します。ロールは admin
, member
, anonymous
のいずれかです。
このテーブルを利用し、add_user()
の中で呼ばれている setup_role()
では、新規登録ユーザーに member
のロールを割り当てています。これにより、新規ユーザーには auth_map
テーブルに保存されている member
の機能が利用可能になります。実装内容は以下の通りです。
-- テーブルに対する権限の設定
-- 呼び出した側で、必ず 'FLUSH PRIVILEGES' を実行する事
DELIMITER //
CREATE PROCEDURE setup_tables_priv(
_role VARCHAR(20), -- ロール名 (auth_map.role で指定された内容のいずれか)
_host VARCHAR(20), -- ホスト名 ('localhost' か '%' のいずれか)
_login_name VARCHAR(12) -- 権限を与えたいユーザーのログイン名
)
BEGIN
-- システムテーブルにデータを投入し、_login_name で指定したユーザーが機能を利用可能なように設定する
INSERT
INTO mysql.tables_priv(
Host, Db, User, Table_priv, Table_name)
SELECT
_host, DATABASE(), _login_name, auth_type, auth_target
FROM
auth_map
WHERE
role = _role
AND auth_type NOT IN ('FUNCTION', 'PROCEDURE');
END;
//
DELIMITER ;
-- ストアドプロシージャー・ストアドファンクションに対する権限の設定
-- 呼び出した側で、必ず 'FLUSH PRIVILEGES' を実行する事
DELIMITER //
CREATE PROCEDURE setup_procs_priv(
_role VARCHAR(20), -- ロール名 (auth_map.role で指定された内容のいずれか)
_host VARCHAR(20), -- ホスト名 ('localhost' か '%' のいずれか)
_login_name VARCHAR(12) -- 権限を与えたいユーザーのログイン名
)
BEGIN
-- システムテーブルにデータを投入し、_login_name で指定したユーザーが機能を利用可能なように設定する
INSERT
INTO mysql.procs_priv(
Host, Db, User, Routine_name, Routine_type, Grantor, Proc_priv)
SELECT
_host, DATABASE(), _login_name, auth_target, auth_type, USER (), 'Execute'
FROM
auth_map
WHERE
role = _role
AND auth_type IN ('FUNCTION', 'PROCEDURE');
END;
//
DELIMITER ;
-- 権限の設定
-- 呼び出した側で、必ず 'FLUSH PRIVILEGES' を実行する事
DELIMITER //
CREATE PROCEDURE setup_role(
_role VARCHAR(20), -- ロール名(auth_map.role で指定された内容のいずれか)
_login_name VARCHAR(12) -- ログインユーザー名
)
BEGIN
-- ローカルアクセス用 (localhost) と、ネットワーク経由用 ('%') の2つのアカウント用に権限を設定している
CALL setup_tables_priv(_role, '%', _login_name);
CALL setup_tables_priv(_role, 'localhost', _login_name);
CALL setup_procs_priv(_role, '%', _login_name);
CALL setup_procs_priv(_role, 'localhost', _login_name);
END;
//
DELIMITER ;
テーブルの権限設定は mysql.tables_priv
システムテーブルを、ストアドプロシージャー、ストアドファンクションの権限設定は mysql.procs_priv
システムテーブルを操作する事で行っています。これらは本来 GRANT
文で行うべき操作ですが、CREATE USER
の場合と同じく対象を文字列として扱いにくいために直接システムテーブルを操作する事にしました。
users テーブルの参照
ユーザーのプロフィール(users.profile
カラム) 等はログイン者の権限状態に応じて表示を許可するかどうかが決まるので、users
テーブルを直接閲覧できるようにするわけには行きません。そこで以下のようなビューを用意しました。
-- ユーザー情報ビュー
CREATE VIEW users_view AS
SELECT
u.user_id -- ユーザーID
, u.login_name -- ログイン名
, safe_profile( -- プロフィール(権限管理あり)
my_login_name()
, u.login_name
, u.profile
, u.profile_open
, f.user_id
) AS profile
FROM
users u
LEFT JOIN users_friend_users f
ON f.user_id = u.user_id
AND f.friend_user_id = user_id(my_login_name())
ORDER BY
u.user_id DESC;
ここで出てくる3つのストアドファンクションは、以下のような物です。
名前 | 機能 |
---|---|
my_login_name() | 現在のユーザーのログイン名を返す |
user_id() | ログイン名に紐づくユーザーIDを返す |
safe_profile(ログイン中ユーザーのログイン名, 対象ユーザーのログイン名, プロフィール本文, プロフィール公開範囲, フレンドかどうか) | ログイン中のユーザーと、対象ユーザーのプロフィール公開範囲を考慮し、ログイン中ユーザーにプロフィールを公開可能ならプロフィール本文を、そうでなければ NULL を返す |
my_login_name()
は組み込み関数であるUSER()
とほぼ同じ機能ですが、USER()
関数が member_1@localhost
のようにホスト名を含めたアカウント名を返すのに対し、my_login_name()
は @ の左側だけを返します。ソースは省略しますが、USER()
関数の戻り値を文字列操作しているだけです。
user_id()
はログイン名を引数に取り、ユーザーIDを返します。ソースは省略します。
safe_profile()
は以下の通りです。CASE で分岐しているだけですが慎重にテストするべきポイントになります。
-- 対象との関係に応じてプロフィールが閲覧可能かどうかを返す
-- 閲覧可能な場合はプロフィールを、そうでなければ NULL を返す
DELIMITER //
CREATE FUNCTION safe_profile(
_my_login_name VARCHAR(12), -- ログイン中のユーザーのログイン名
_target_login_name VARCHAR(12), -- プロフィールを取得したい対象ユーザーのログイン名
_profile VARCHAR(200), -- 対象ユーザーのプロフィール
_profile_open ENUM('none', 'friends', 'members', 'open'), -- 対象ユーザーのプロフィール公開範囲
_is_friend INT -- 対処ユーザーがログイン中のユーザーをフレンド登録しているかどうか
) RETURNS VARCHAR(200) DETERMINISTIC
BEGIN
IF _profile_open <> 'none'
AND _profile_open <> 'friends'
AND _profile_open <> 'members'
AND _profile_open <> 'open'
THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'The value of argument _profile_open is invalid.', MYSQL_ERRNO = 1001;
END IF;
RETURN
CASE WHEN
-- 自分には常に公開する
(_my_login_name = _target_login_name)
-- フレンドに公開するかどうかをチェック
OR (_profile_open = 'friends' AND _is_friend IS NOT NULL)
-- 会員に公開するかどうかををチェック
OR (_profile_open = 'members' AND _my_login_name <> 'anonymous')
-- 'open' が指定されていれば常に公開する
OR (_profile_open = 'open')
THEN
_profile
ELSE
NULL
END;
END;
//
DELIMITER ;
このようなストアドファンクションを利用したビューのみを公開する事で、users
テーブルのデータを保護する事ができるようになりました。なお、自分のレコードを参照する時は、以下のようなストアドプロシージャーで行います。
-- ログイン中のユーザーの情報を返す
DELIMITER //
CREATE PROCEDURE my_info()
BEGIN
SELECT
user_id -- ユーザー ID
, login_name -- ログイン名
, profile -- プロフィール
, email -- メールアドレス
, profile_open -- プロフィール公開範囲
, is_admin -- 管理者かどうか
, my_role() AS role -- ロール ('admin', 'member', 'anonymous' のいずれか)
FROM
users
WHERE
login_name = my_login_name();
END;
//
DELIMITER ;
基本的には users
テーブルの内容を返すだけですが、my_role()
というストアドファンクションを利用しています。これは自分のロールを返すストアドファンクションで、anonymous
, member
, admin
のいずれかを返します。ソースは省略します。
users テーブルの更新
自分のデータを更新する時も専用のストアドプロシージャーを利用します。以下の通りです。
-- ログイン中のユーザーの情報を更新する
DELIMITER //
CREATE PROCEDURE update_me(
_password VARCHAR(20), -- パスワード (NULL を指定すると更新しない)
_profile VARCHAR(200), -- プロフィール (NULL を指定すると更新しない)
_email VARCHAR(40), -- メールアドレス (NULL を指定すると更新しない)
_profile_open ENUM('none', 'friends', 'members', 'open') -- プロフィール公開設定 (NULL を指定すると更新しない)
)
BEGIN
CALL update_user(my_login_name(), _password, _profile, _email, _profile_open);
END;
//
DELIMITER ;
update_me()
の中ではさらに update_user()
を呼んでいます。update_user()
は root アカウントのみが利用可能なストアドプロシージャーで、全ユーザーの更新が可能です。ログイン名を指定してラップする事で、自分のアカウントのみを更新できるようにしています。update_user()
のソースは省略します。
フレンドの登録・削除
users_friend_users
テーブルはログインしていれば参照可能ですが、書き込みはできないようになっています。フレンドの追加、削除はやはりストアドプロシージャーを利用します。やっている事は単純なusers_friend_users
テーブルの操作です。
特筆すべき点は無いのでソースは省略します。
データベースの設定のまとめ
以上でデータベースにおける重要な設定はほぼ紹介できたと思います。その他にもいくつかのストアドプロシージャー等がありますが、省略します。
通常ならアプリケーションが担うべき多くの機能をストアドプロシージャーで実装する事になりました。Rails なんかで作ればあっと言う間に仕上がってしまいそうな処理ばかりですが、慣れていないせいもあり、それなりに時間が掛かってしまいました。
サーバーサイドの実装
今まで見てきた通り、SDCBAR ではビジネスロジックに当たる部分をデータベースのストアドプロシージャーで実装します。従って従来サーバーサイドでやっていた事の大部分を担う事になるのですが、従来的なサーバーサイドのコーディングが無くなるわけではありません。
サーバーサイドは PHP で実装しています。以下3つのファイルを設置しており、それぞれが API のエンドポイントです。
API名 | 機能 |
---|---|
api/login.php | ログインを行いセッション情報を返す |
api/query.php | SQL を実行する |
api/logout.php | セッション情報を破棄する |
まず api/login.php
でログインを行いセッションを確立します。その後に api/query.php
で SQL を実行するという流れになります。
ログイン
ログイン処理は、以下の通りです。
<?php
try {
// すべてのエラーで例外を発生させるようにする
set_error_handler(
function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
}
);
// JSON 形式の設定ファイルから DBホスト名、DB名を読み込む
$config = json_decode(file_get_contents(__dir__ . '/../config.json'));
// データベースとの接続処理
$db = new \PDO(sprintf('mysql:host=%s;dbname=%s',
$config->db->host, $config->db->db),
$_POST['loginName'], $_POST['password']);
// エラー発生時には例外を発生させる
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 接続に成功したら、ログイン名とロール名を取得する
[$role, $login_name] = $db->query('SELECT my_role(), my_login_name()')->fetch(\PDO::FETCH_NUM);
// セッションを開始し、入力されたログイン名、パスワードをセッション変数に保存する
session_start();
$_SESSION['loginName'] = $_POST['loginName'];
$_SESSION['password'] = $_POST['password'];
// ログイン名、ロール名を出力
header('Content-Type: Application/json');
echo json_encode(['role' => $role, 'loginName' => $login_name]);
} catch (\Exception $e) {
// エラー発生時には例外オブジェクトをクライアントに返す
restore_error_handler();
header('HTTP/1.0 400');
header('Content-Type: Application/json');
echo json_encode(['error' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'message' => $e->getMessage()
]]);
}
リクエストボディで送られてきたユーザー名、パスワードに従い、PDO でデータベース接続を行っています。成功した場合はセッション変数にログイン名、パスワードを保存しておきます。以後、これらのアカウント情報を利用してデータベースに再接続します。セッションキーは PHP のデフォルトの実装により、Cookie でクライアントに送信されます。
エラー発生時は例外オブジェクトの内容を JSON で返します。クライアント側に詳細なエラー情報を返すべきではないというご意見はあると思いますが、検証用アプリケーションなのでそのようにしています。
SQL 実行
DCBAR のコアとなる SQL を実行する API です。「世界最悪のログイン処理コード」における apiService.sql()
のサーバー側実装という事になります。
<?php
try {
// すべてのエラーで例外を発生させるようにする
set_error_handler(
function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
}
);
// SQL が指定されているかどうかを確認
if (!isset($_POST['sql'])) {
throw new Exception('SQL not specified');
}
// セッションを開始し、ログイン名、パスワードを取得
// 保存されていない場合は匿名ユーザーでアクセスする
session_start();
$login_name = 'anonymous';
$password = '';
if (isset($_SESSION['loginName']) && isset($_SESSION['password'])) {
$login_name = $_SESSION['loginName'];
$password = $_SESSION['password'];
}
// 設定情報ファイルから DB ホスト名、DB名を、ログイン名、パスワードをセッション情報から
// 取得してデータベースに接続する
$config = json_decode(file_get_contents(__dir__ . '/../config.json'));
$db = new \PDO(sprintf('mysql:host=%s;dbname=%s',
$config->db->host, $config->db->db), $login_name, $password);
// エラー発生時に例外を発生させる
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 出力処理。UPDATE, INSERT 等、出力が存在しない SQL が実行された場合には空の配列だけを返す事とする
$out = [];
$stm = $db->query($_POST['sql']);
if ($stm->columnCount()) {
$out = $stm->fetchAll(\PDO::FETCH_ASSOC);
}
header('Content-Type: Application/json');
echo json_encode($out);
} catch (\Exception $e) {
// エラー発生時には例外オブジェクトをクライアントに返す
restore_error_handler();
header('HTTP/1.0 400');
header('Content-Type: Application/json');
error_log(sprintf("%s(%s): %s", $e->getFile() ,$e->getLine(), $e->getMessage()));
echo json_encode(['error' => [
'file' => $e->getFile(),
'line' => $e->getLine(),
'message' => $e->getMessage()
]]);
}
セッションからアカウント情報を取得し、データベースに接続します。その後、リクエストボディで送られてきた SQL を 何も加工せずにそのまま実行します。以下の部分ですね。
$stm = $db->query($_POST['sql']);
自分で書いていて何ですが、ものすごいインパクトです。なお、SDCBAR が確立されているのか DCBAR 脆弱性が存在するのかは、この部分だけを見ていても判断できません。それを判断するためには、各ストアドプロシージャーや権限設定を細かく確認する必要があります。
ログアウト
api/logout.php
ではセッション情報を破棄します。特に面白い事は無いのでソースは省略します。
サーバーサイドの実装のまとめ
データベース層での PL/SQL によるプログラミング負荷がかなり大きくなっている反面、いわゆるビジネスロジック層のプログラミングは非常にあっさりした物になりました。と言うより、これはビジネスロジックではありません。SQL を RDBMS に丸投げしているだけなので、当然と言えば当然です。
なお、現状では複数のクエリを同一トランザクションで実行できない点は重要課題です。またフェールファーストの原則に従えば、文字エンコーディングのチェック等、最低限のバリデーションチェックはやった方がよいかも知れませんね。
クライアントサイドの実装
クライアントサイドは VueCLI3 で開発しました。ここはほぼ普通の実装に近いのですが、ajax する部分は api/query.php
を主に利用します。ユーザー一覧画面だけソースを紹介しておきます。
<template>
<section class="contents">
<h1>Users</h1>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th>UserID</th>
<th>LoginName</th>
<th>Profile</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.userId">
<td>{{user.userId}}</td>
<td><permit-link :msg="user.loginName" :to="'/users/' + user.userId" /></td>
<td>{{user.profile}}</td>
</tr>
</tbody>
</table>
</section>
</template>
<script>
export default {
data: () => {
return {
users: []
}
},
name: 'users',
mounted : function () {
this.$nextTick(function () {
let params = new URLSearchParams()
params.append('sql',
'SELECT user_id AS userId, login_name AS loginName, profile FROM users_view ORDER BY user_id')
this.$http.post('/api/query.php', params)
.then((res) => {
this.users = res.data
})
})
},
}
</script>
SQL を直接発行している部分は、何回見てもインパクトあります(笑)