Programming Room.
Google Cloud Tips.
GCS Tips.
アクセストークンの取得.2019/11/13
更新 2019/12/13
更新 2020/01/04
更新 2021/05/01
GCSのバケット/オブジェクトに、JSON API/XML APIでアクセスする際に必要となる、アクセストークンの取得について説明します。
おしながき
概要.
非公開のGCSのバケット/オブジェクトに、JSON API/XML APIでアクセスする際には、そのアクセスが許可されたものであることを示さなければなりません。これは基本的にはリクエストエンドポイントにアクセストークンを渡すことで行います。そのためAPIのコールに先立ってアクセストークンの取得が必要になります。
例外は、私が確認する限り、下記の2パターンです。これらのケースではアクセストークンなしでアクセスできます。
- 公開オブジェクトに対するアクセス。
- 署名付きURLを使ってXML APIにアクセスする場合。
アクセストークンの取得の詳細は、公式ドキュメントでは「Using OAuth 2.0 to Access Google APIs」に説明されています。残念ながら日本語訳は、まだなさそうです。
ユーザ中心のフローによるアクセストークンの取得.
はじめに.
公式ドキュメントを見ると、「Cloud Storage の認証」ページの「APIの認証」の章には下記の説明があります。しかしどこまで読み進めても、下記に出てくる「クライアントライブラリ」のリファレンスが見つかりません。Goのサンプルのみありますが、ブラウザからのアクセスしたい場合は、Goは参考になりません。
アクセス トークンを管理したり更新したりするのは複雑であり、暗号化アプリケーションを直接扱うにはリスクがあるため、検証済みのクライアント ライブラリを使用することを強くおすすめします。
そのためここではクライアントライブラリを使わずにアクセストークンを取得します。
「Cloud Storage の認証」ページの「OAuth 2.0」-「認証」の章には、2種類の認証フローが書かれています。ここでは特定のユーザに許可するケースを想定し、「ユーザ中心のフロー」で説明します。
準備.
アクセストークンは簡単に取得できません。まずは準備が必要です。準備は1回行えば、取り消すまで有効です。
A). OAuth同意画面の作成.
まずOAuth同意画面を作成しなければなりません。これをやっておかないと、次のステップの認証情報の作成が進まなくなります。
OAuth同意画面は、非公開データへのアクセス権を与えることを、ユーザから同意を得るためのものです。最初にアクセストークンまたはアクセスコードを取得しようとしたときに、Googleの認証サーバによって表示されます。これにユーザが同意しないと、アクセストークン/アクセスコードは取得できません。
GCPコンソールのメニューから「APIとサービス」-「OAuth同意画面」とたどると、同意画面に表示する内容の設定ページが表示されます。
現在は(2020/08)は、最低限「アプリケーション名」と「サポートメール」だけ設定しておけば、同意画面の作成は可能なようです。必要な項目は将来追加される可能性があります。その他の項目もユーザに提示するものなので、できるだけ埋めるべきです。特に「Google
APIのスコープ」は、ここでアクセス権を設定しようとしているものなので、正確に設定する必要があると思います。しかし「Google APIのスコープ」の設定は同意画面に表示されるだけで、実際にAPIのアクセスに影響するわけではないようです。
B). クレデンシャル(クライアントID)の取得.
次にクライアントIDをGCPコンソールから取得します。クライアントIDは、アクセストークン/アクセスコードを取得するために必要になります。
GCPコンソールのメニューから「APIとサービス」-「認証情報」とたどると、プロジェクトに設定されている認証情報のリストが表示されます。何も設定されていないと、このページの説明と「認証情報を作成」ボタンのみが表示されます。
新しく認証情報を作成するには、「認証情報を作成」ボタンを押します。
右のようにドロップダウンリストが表示されるので、「OAuth クライアントID」を選択します。
「OAuthクライアントIDの作成」画面に変わります。ブラウザからアクセスする場合は、「ウェブアプリケーション」を選択します。すると右の様に「ウェブアプリケーション」としての設定項目が表示されます。いずれの項目も後から修正/削除が可能です。
「承認済みの JavaScript 生成元」は、アプリのドメインです。
「承認済みのリダイレクト URI」は、OAuth同意画面がURI遷移によって表示され、ユーザが同意した後、ここで指定したURIに遷移されます。指定がないと、同意後にユーザに表示するページがなくなってしまい、ユーザは途方に暮れてしまいます。このURIは複数登録できます。実際にどのURIに遷移するかは、後に説明する「1. アクセストークンのリクエスト.」で選択します。
入力が終わったら「作成」ボタンを押すと、クライアントIDとクライアントシークレットが表示されますので、これらを記録しておきます。
もしクライアントIDやクライアントシークレットがわからなくなってしまっても、GCPコンソールの認証情報のリストで、OAuth2.0クライアントIDの行の右端のダウンロードボタンでJSONをダウンロードすれば、その中に記載されています。こんな感じで、「OAuthクライアントIDの作成」画面で入力したもの、それに加えて認証サーバのURIとかもあったりします。
{ "web": { "client_id": "クライアントID", "project_id": "プロジェクトID", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_secret": "クライアントシークレット", "redirect_uris": [ "承認済みのリダイレクトURI1", ... ], "javascript_origins": [ "承認済みのJavaScript生成元1", ... ], } }
以上、ここまでがアクセストークンの取得の準備です。
クライアントサイドで制御する場合.
クライアントのブラウザ上でJavaScriptを使ってアクセストークンを取得するケースの手順です。クライアントに永続的に使えるデータベースなどがない場合に、この手順を使用します。
アクセストークンはGoogleの認証サーバにアクセスして取得します。公式ドキュメントでは「OAuth 2.0 for Client-side Web Applications」ページの「Obtaining OAuth 2.0 access tokens」の章に説明されています。1. アクセストークンのリクエスト.
アクセストークンには有効期限があるので、GCSへのアクセスの直前に取得するのがベストです。
認証サーバのURIは下記です。
https://accounts.google.com/o/oauth2/v2/auth
これにクエリーパートでパラメータを追加して、URI遷移でアクセスします。
以下にパラメータを説明しますが、ここではオプションのパラメータは省きます。
名前 | 必要性 | 説明 |
---|---|---|
client_id | 必須 | クライアントID。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントIDを値として渡します。 |
redirect_uri | 必須 | 認証後にユーザに表示するページのURIです。 上記「B). クレデンシャル(クライアントID)の取得.」の「承認済みのリダイレクト URI」」に登録したうちのどれか1つです。 大文字小文字の違いも含めて正確にどれかと一致する必要があります。 URIなので、URIエンコードを忘れずに。 |
response_type | 必須 | 認証後に取得するものを選択します。 token = アクセストークンを取得します。ここではこちらを使います。 code = アクセスコードを取得します。 |
scope | 必須 | アクセス許可してほしい範囲を指定します。 値は「OAuth 2.0 Scopes for Google APIs」にリストされています。複数指定したい場合はスペースで区切って並べるそうです。 これもURIのフォーマットなので、URIエンコードを忘れずに。 |
access_type | 強く推奨 | アクセストークンの更新方法を指定します。 online = ユーザが操作して更新。ここではこちらを使います。デフォルトなので、指定しなくてもOk。 offline = ユーザが操作できない状態で、アプリケーションがアクセストークンを更新する。 |
state | 推奨 | 同意画面の前後でアプリケーションの任意の状態を保持・復元するために、使用するそうです。 同意画面はURI遷移ですので、ajaxの様に状態を保持できません。そのためのパラメータで、アプリケーション固有のデータを、遷移後の画面に渡します。 同意後の遷移先(redirect_uriで指定したURI)では、このパラメータがそのままフラグメント(URIの"#"以降)に渡されます。 複数のパラメータを渡したい場合やURLセーフでない文字を含む場合は、BASE64エンコードするのが安全なようです。 |
2. ユーザによる同意.
Googleの認証サーバの画面に遷移します。以下、ユーザに表示される画面の説明です。
Googleアカウントにログインしていない場合、Googleアカウントログイン画面が表示されますので、「バケットの権限設定.」で権限を付与したGoogleアカウントでログインします。
ログインすると、同意画面の前にこのような画面が表示されることがあります。この場合は画面左下のリンク「詳細」をクリックすると...
その下に「<プロジェクトID> (安全ではないページ) に移動」のリンクが増えます。このリンクをクリックすると...
やっと同意画面に遷移します。
最初に左の画面が出てきます。
ここで「許可」を選択すると、次に表示される右の画面でも同意したことになっています。
左の画面で「拒否」を選択すると、右の画面に遷移し、さらにユーザの確認を待ちます。ここで権限にチェックし、「許可」を押せば同意が成立します。
右の画面で「キャンセル」すると、同意しなかったことになります。アクセストークン/アクセスコードは得られません。
3. アクセストークンの取得.
同意画面でユーザが同意すると、redirect_uriで指定したURIに遷移されます。このときフラグメントにアクセストークンが付加されていますので、解析して取り出します。公式ドキュメントの例では"&"で区切って有効期間なども送られてくるようです。"#"以降全部がアクセストークンというわけではない可能性があるので、"&"までで区切る解析が必要です。
得られたアクセストークンは有効期限の期間内なら、何度でも使用可能です。もちろんscopeで指定した範囲内だけですが。
https://oauth2.example.com/callback#access_token=4/P7q7W91&token_type=Bearer&expires_in=3600 ~~~~~~~~~ ↑アクセストークン
同意画面でユーザが同意しなかった場合、redirect_uriで指定したURIには、以下の様にエラーが返ってきます。エラーの場合でもstateパラメータがあれば、そのまま付加されます。
https://oauth2.example.com/callback#error=access_denied
4. アクセストークンの再取得.
有効期限が切れたアクセストークンを使用すると、エラーになります。取得したアクセストークンの有効期限が切れた後にJSON API/XML APIを使用したい場合、アクセストークンの再取得が必要です。
アクセストークンの再取得は、「1. アクセストークンのリクエスト.」からやり直すだけです。
サーバサイドで制御する場合.
GAE/GCEなどを使ってサーバ側でアクセストークン取得の手順を制御するケースの手順です。Datastore/Firestoreなどの永続的データベースへの保存が可能なことが条件になります。
公式ドキュメントでは「Using OAuth 2.0 for Web Server Applications」ページの「Obtaining OAuth 2.0 access tokens」以降の章に説明されています。それ以前の章の内容は、「クライアントサイドで制御する場合.」と同じで、ここでは「準備.」で説明済みです。
1. 認証コードのリクエスト.
認証サーバのURIは下記です。「クライアントサイドで制御する場合.」と同じです。
https://accounts.google.com/o/oauth2/v2/auth
これにクエリーパートでパラメータを追加してURI遷移でアクセスする点も「クライアントサイドで制御する場合.」と同じですが、指定すべきパラメータの値は異なります。例によってオプションのパラメータは省きます。
名前 | 必要性 | 説明 |
---|---|---|
client_id | 必須 | クライアントID。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントIDを値として渡します。 |
redirect_uri | 必須 | 認証後にユーザに表示するページのURIです。 上記「B). クレデンシャル(クライアントID)の取得.」の「承認済みのリダイレクト URI」」に登録したうちのどれか1つです。 大文字小文字の違いも含めて正確にどれかと一致する必要があります。 URIなので、URIエンコードを忘れずに。 |
response_type | 必須 | 認証後に取得するものを選択します。 token = アクセストークンを取得します。 code = 認証コードを取得します。ここではこちらを使用します。 |
scope | 必須 | アクセス許可してほしい範囲を指定します。 値は「OAuth 2.0 Scopes for Google APIs」にリストされています。複数指定したい場合はスペースで区切って並べるそうです。 これもURIのフォーマットなので、URIエンコードを忘れずに。 |
access_type | 強く推奨 (サーバサイドの場合は事実上必須) |
アクセストークンの更新方法を指定します。 online = ユーザが操作して更新。デフォルト。 offline = ユーザが操作できない状態で、アプリケーションがアクセストークンを更新する。ここではこちらを使用します。 「クライアントサイドで制御する場合.」の「1. アクセストークンのリクエスト.」ではデフォルトの"online"で十分なのでaccess_typeの指定は必要ありませんでしたが、こちらは"offline"の指定が必要です。 |
state | 推奨 | 同意画面の前後でアプリケーションの任意の状態を保持・復元するために、使用するそうです。 同意画面はURI遷移ですので、ajaxの様に状態を保持できません。そのためのパラメータで、アプリケーション固有のデータを、遷移後の画面に渡します。 同意後の遷移先(redirect_uriで指定したURI)では、このパラメータがそのままフラグメント(URIの"#"以降)に渡されます。 複数のパラメータを渡したい場合やURLセーフでない文字を含む場合は、BASE64エンコードするのが安全なようです。 |
2. ユーザによる同意.
「クライアントサイドで制御する場合.」の「2. ユーザによる同意.」と同じです。ので省略。
3. 認証コードの取得.
同意画面でユーザが同意すると、redirect_uriで指定したURIに遷移されます。このときクエリーパートに認証コードが付加されていますので、解析して取り出します。「クライアントサイドで制御する場合.」とはフラグメントかクエリーパートかが異なります。公式ドキュメントの例には記載がありませんが、"&"で区切ってスコープやstateなども送られて来ます。"?"以降全部が認証コードというわけではないので、"&"までで区切る解析が必要です。
認証コードを取得できるタイミングはこの1回限りです。
https://oauth2.example.com/callback?code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp7&scope=https://www.googleapis.com/auth/devstorage.read_write ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ↑認証コード
同意画面でユーザが同意しなかった場合、redirect_uriで指定したURIには、以下の様にエラーが返ってきます。成功の場合と同じでクエリーパートで返ってくる点が、「クライアントサイドで制御する場合.」と異なります。エラーの場合でもstateパラメータがあれば、そのまま付加されます。
https://oauth2.example.com/callback?error=access_denied
4. 認証コードをアクセストークンに変換.
もう一度Googleの認証サーバにアクセスして、先ほど取得した認証コードを、アクセストークンとリフレッシュトークンに変換します。
このサーバのURIは以下のとおりで、なぜか「1. 認証コードのリクエスト.」や、「クライアントサイドで制御する場合.」の「1. アクセストークンのリクエスト.」とは別です。
https://oauth2.googleapis.com/token
またパラメータもURI渡しではなく、POSTメソッドを使ってリクエストのボディで渡します。パラメータの詳細は以下のとおりです。
名前 | 説明 |
---|---|
code | 認証コード。 「3. 認証コードの取得.」で取り出した、認証コードを値として渡します。 |
client_id | クライアントID。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントIDを値として渡します。 |
client_secret | クライアントシークレット。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントシークレットを値として渡します。 |
redirect_uri | 認証後にユーザに表示するページのURIです。 最初に「1. 認証コードのリクエスト.」で指定したのと同じURIを渡さないと、エラーになります。 このリクエストはURI遷移しませんので指定の必要さえないはずですが、このような仕様です。 恐らく、不正アクセスを見分けるためだけに渡す仕様にしてあると想像しています。 |
grant_type | "authorization_code" を渡します。 |
レスポンスはJSONが返ってきます。その内容は以下のとおりです。
名前 | 説明 |
---|---|
access_token | 取得したアクセストークン。 |
refresh_token | リフレッシュトークン。次に説明する「5. アクセストークンの更新.」で使います。 リフレッシュトークンが取得できるタイミングは、最初にこのクライアントID/ユーザの組み合わせでアクセストークンへの変換が行われたときのみです。 2回目以降のアクセストークンへの変換では、リフレッシュトークンは通知されませんが、最初の変換で通知されたリフレッシュトークンがそのまま使えます。 そのためDatastoreなどの永続的データベースへの保存が必要です。 |
expires_in | 取得したアクセストークンの残りの有効時間。単位は秒。 有効期限の起点は、リクエストした時刻のようです。 |
token_type | 常に "Bearer" です。 |
認証コードは一度アクセストークンに変換してしまうと、効力を失います。同じ認証コードで複数回アクセストークンに変換すると、2回目以降はエラーになります。
またリフレッシュトークンは、クライアントID/ユーザの組み合わせで1つしか発行されません。これは「Refreshing an access token (offline access)」の最後に、注意書きがあります。
5. アクセストークンの更新.
アクセストークンの有効期限は取得から1時間です。有効期限を過ぎたアクセストークンを使用すると、エラーになります。有効期限を過ぎたら、リフレッシュトークンを使ってアクセストークンを更新し、更新したアクセストークンを使用することで、JSON
API/XML APIの利用を続けられます。
アクセストークンの更新リクエストは、「4. 認証コードをアクセスコードに変換.」と同じURIを使用しますが、リクエストボディで渡すパラメータが異なります。
名前 | 説明 |
---|---|
refresh_token | リフレッシュトークン。 「4. 認証コードをアクセスコードに変換.」で取得した、リフレッシュトークンを値として渡します。 |
client_id | クライアントID。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントIDを値として渡します。 |
client_secret | クライアントシークレット。 上記「B). クレデンシャル(クライアントID)の取得.」の最後で取得したクライアントシークレットを値として渡します。 |
grant_type | "refresh_token" を渡します。 |
レスポンスも同じくJSONで返ってきます。内容は「4. 認証コードをアクセスコードに変換.」とほぼ同じですが、リフレッシュトークンは含まれません。
取得済みのアクセストークンの有効期限が切れる前に更新することも可能です。この場合、取得済みのアクセストークンはキャンセルされず、新たに更新で取得したアクセストークンも、両方とも有効です。ただし有効期限はそれぞれに独立して持っているので、取得済みのほうが早く有効期限を迎えます。更新というよりは、追加発行と理解した方が正確と思います。
トークンの取り消し.
認証コード/アクセストークン/リフレッシュトークンは、ユーザに対して発行されます。ユーザのアプリケーションからの脱退のように、もうユーザはアプリケーションを使用しない場合、認証コード/アクセストークン/リフレッシュトークンももう必要なくります。このような場合に、取り消すことができます。
取り消しのリクエストは以下のURIを使用します。
https://accounts.google.com/o/oauth2/revoke
パラメータはクエリーパートで渡しますが、パラメータは1種類しかありません。公式ドキュメントの「Revoking a token」の例では Content-typeヘッダを指定していますが、GETメソッドでリクエストボディもないので、Content-typeヘッダは不要です。明らかに他からのコピペで編集ミスです。
名前 | 説明 |
---|---|
token | 取り消したいアクセストークンまたはリフレッシュトークン。どちらを渡しても効果は同じです。 アクセストークンの場合は有効期限内である必要があります。有効期限の切れたアクセストークンを渡すと、エラーになります。 |
結果はレスポンスのステータスコードで判断できます。200が返ってくれば成功です。
取り消しが成功すると、このクライアントID/ユーザの組み合わせで発行されていた、すべての認証コード/アクセストークン/リフレッシュトークンが取り消され、無効になります。渡したトークンのみが取り消されるのではありません。認証サーバが保持しているクライアントID/ユーザのデータを初期化している感じです。
取り消したクライアントID/ユーザの組み合わせで、再度「1. 認証コードのリクエスト」の手順から、再取得することが可能です。この場合は必ずGoogleアカウントへのログインから行われます。
単にユーザがログアウトしたくらいなら、アクセストークンを取り消す必要はありません。オフラインでアプリケーションが動作する場合に、アクセストークンが必要になるケースがあります。
注意事項.
access_type=offlineの指定について.
「サーバサイドで制御する場合.」は "access_type = offline" を指定しますが、このクライアントID/ユーザの組み合わせでの初めての「1. 認証コードのリクエスト.」から指定する必要があります。2回目以降の「1. 認証コードのリクエスト.」の実行から指定しても無視され、リフレッシュトークンが通知されません。
そのため1つのクライアントID/ユーザの組み合わせの中で、"access_type=online" と "access_type=offline"
を混在さるような使い方はできません。そんな変なつくりにする方がおかしいですが。
ただし「トークンの取り消し.」の手順でトークンを取り消してしまえば、認証サーバが保持しているデータは初期化されるので、その後は "access_type = offline"
の指定も有効になります。
リフレッシュトークンの保持・更新.
「サーバサイドで制御する場合.」では、クライアントID/ユーザの組み合わせで初めての「4. 認証コードをアクセスコードに変換.」でリフレッシュトークンが通知されます。その後「トークンの取り消し.」をすることなく、もう一度「1. 認証コードのリクエスト.」から「4. 認証コードをアクセスコードに変換.」まで実行すると、リフレッシュトークンは通知されません。最初の「4. 認証コードをアクセスコードに変換.」で通知されたリフレッシュトークンがまだ有効であるためです。そのため「5. アクセストークンの更新.」に備えて、リフレッシュトークンを保持しておく必要があります。
これについては「Refreshing an access token (offline access)」の最後に、以下の注意書きがあります。
Note that there are limits on the number of refresh tokens that will be issued; one limit per client/user combination, and another per user across all clients. (中略) If your application requests too many refresh tokens, it may run into these limits, in which case older refresh tokens will stop working.
また「Step 5: Exchange authorization code for refresh and access tokens」の最後の方にある『★Important:〜』の最後には、下記の説明があります。
As such, if your application loses the refresh token, the user will need to repeat the OAuth 2.0 consent flow so that your application can obtain a new refresh token.
つまりリフレッシュトークンもリフレッシュされるケースがあるようですが、私が試した限りではこのケースに遭遇しませんでした。リフレッシュトークンが更新されるケースも想定して実装する必要があるようです。まだ私が試していない「Incremental authorization」が怪しいかなと思っています。
安全性について.
「クライアントサイドで制御する場合.」では、クライアントIDがJavaScriptのソースなどの形でユーザに見えてしまうと、セキュリティ上の問題になる可能性があります。フロントエンドをGAE/GCEで作っているのなら、クライアントIDはDatastoreに保存しておいてGAE/GCE経由で読み出して、GAE/GCEから認証サーバにリダイレクトとか、工夫したほうがいいと思われます。
それでも権限を割り当てられたユーザに成りすませない限り、アクセストークンは取得できません。また仮に悪意ある人がアクセストークンを取得する事態になっても、権限を必要以上に与えていなければ、影響は最小限にできます。
「サーバサイドで制御する場合.」なら、サーバの実装でクライアントID/クライアントシークレット/認証コード/リフレッシュトークンをユーザから隠蔽する実装にすれば、危険を回避できると思います。
サービスアカウントによるアクセストークンの取得.2019/12/13
「Cloud Storage の認証」ページの「APIの認証」の章に記載の、『サーバ中心のフロー』によって、アクセストークンを取得する手順を説明します。
GAEのライブラリを利用.
GAEには、サービスアカウントによるアクセストークンを取得する AppIdentityService.getAccessToken()メソッドが用意されています。
AppIdentityService.GetAccessTokenResult getAccessToken(java.lang.Iterable<java.lang.String> scopes)
引数のscopesは、アクセス許可を要求するスコープです。Iterableなので複数指定できます。個々のスコープは「OAuth 2.0 Scopes for Google APIs」にリストされていますが長くて探しにくいので、OAuth2.0 PlaygroundのStep1のリストから探してコピペしてくるほうが楽な気がします。
戻り値のAppIdentityService.GetAccessTokenResultクラスには、アクセストークンを取得するgetAccessToken()メソッドと、満了時刻を取得するgetExpirationTime()メソッドの2つしかありません。
以下は、このAppIdentityService.getAccessToken()メソッドを使用した、Javaのサンプルです。スコープを複数指定した例です。スコープは利用するAPIに応じて編集してください。
List<String> scopes = Arrays.asList( // 内容は必要に応じて編集 "https://www.googleapis.com/auth/pubsub", // Pub/Subの利用 "https://www.googleapis.com/auth/devstorage.read_write", // ストレージのリード・ライト ); AppIdentityService.GetAccessTokenResult tokenres = appIdentityService.getAccessToken(scopes); String accesstoken = tokenres.getAccessToken(); // アクセストークン
取得したアクセストークンは1時間の有効期限があります。この有効期限なら指定したスコープの機能を使用できるので、以前に取得済みの有効期限内のアクセストークンがあれば、再取得はそのアクセストークンが返されるようです。つまり再取得となった場合は、有効期限が1時間に満たないケースがあるので、JSON APIのコール直前にアクセストークンを取得するのが安全です。
Copyright 2005-2023, yosshie.