# 会員連携

> 自分のサイトにログインした会員を、ニックネーム・ID・レベル・権限のままチャットルームに入室させる流れと、入室できないときの確認点を説明します。

会員連携を使うと、自分のサイトの会員がサイトでのニックネーム・ID・レベル・権限のままチャットルームに入室します。自分のサーバーが入室情報にチャットルームのトークンで署名し、uchatは署名が正しい入室だけを受け付けるため、ほかの人のニックネームをなりすますことはできません。

## 動作の流れ {#how-it-works}

1. 会員が自分のサイトのページを開きます。
2. 自分のサーバーが、ログイン中の会員情報(`nick`、`id`、`level`、`auth` など)とチャットルームID、現在時刻をまとめ、チャットルームのトークンで署名した文字列を作ります。
3. その文字列を `<u-chat user_data="...">` に入れてページを返します。
4. チャットサーバーが署名を確認し、正しければその情報で入室させます。

トークンは署名を作るためだけに使い、ページには出しません。署名文字列を作るルールとフィールドの全体は[会員連携の署名仕様](/ja/doc/3.7)が正式な資料です。

## 有効にする手順 {#enable}

1. 管理ページの**会員連携**メニューで**会員連携**をオンにします。
2. **設置コード**メニューで**サイトの種類**(PHP、Node.js、Python、Java、GnuBoardなど)を選び、**コードを作成**を押します。このコードにはチャットルームのトークンが含まれています。
3. コード内の会員情報の変数を自分のサイトに合わせて書き換え、サーバーのコードに入れます。

会員連携をオンにした瞬間から、署名のない入室は拒否されます(エラー203)。先にサイトのコードを書き換えてからオンにするほうが安全です。ログインしていない訪問者にも署名が必要です → [ログインしていない訪問者](#guests)。GnuBoardやWordPressなどのCMSは[5分で設置](/ja/doc/1.2#cms)を参照してください。

## ログインしていない訪問者 {#guests}

会員連携がオンのチャットルームは、**すべての入室**に署名が必要です。ログインしていない訪問者にも `user_data` を渡してください。渡さないとエラー203で入室できません。

- ニックネーム・IDなしで、チャットルームIDと時刻だけを署名するとゲストとして入室します。ゲストのニックネームは自動で付きます。
- 下のPHP・Node.js・Python・Javaのコードは、会員情報の変数が空ならそのままゲストの署名を作ります。
- 会員かどうかで `<u-chat>` や `user_data` を省くコードを別に書かないでください。

```js
// ログインしていれば会員情報、していなければ空の情報で署名します。ゲストにも user_data を省きません。
const userData = uchatUserData(user ? { nick: user.nickname, id: user.id } : {});
```

## PHP {#php}

管理画面が生成するPHPコードです。`$joinData` の会員情報だけを自分のサイトの変数に置き換えます。PHP 5.6以上に標準で含まれる関数(`hash_hmac`、`bin2hex`、`rawurlencode`)だけを使うので、別途インストールするものはありません。

```php
<?php
if(!function_exists('uchat_array2data')) {
	function uchat_array2data($arr) {
		$arr['time'] = time();
		$token = $arr['token'];
		unset($arr['token']);
		ksort($arr, SORT_STRING);
		$msg = 'uchat-v2';
		$out = array();
		foreach ($arr as $k => $v) {
			$v = (string)$v;
			if ($v === '') continue;
			$msg .= "\n".$k.'='.bin2hex($v);
			$out[] = $k.' '.rawurlencode($v);
		}
		$out[] = 'hash v2.'.hash_hmac('sha256', $msg, $token);
		return implode('|', $out);
	}
}
$joinData = array();
$joinData['room'] = 'my-room';
$joinData['token'] = 'チャットルームのトークン'; // 絶対に公開しないでください
$joinData['nick'] = $nicknameVariable;
$joinData['id'] = $idVariable;
$joinData['level'] = $levelVariable;
$joinData['auth'] = ''; // admin, subadmin, member, guestのいずれか。空ならIDがあれば会員、なければゲスト
$joinData['icons'] = $iconUrlVariable;
?>
<script async src="https://client.uchat.io/uchat.js"></script>
<u-chat room='<?php echo $joinData['room'];?>' user_data='<?php echo uchat_array2data($joinData); ?>' style="display:inline-block; width:100%; max-width:500px; height:500px;"></u-chat>
```

サイトの管理者にだけチャットルームの管理者権限を与えるには、`$joinData['auth'] = $is_admin ? 'admin' : '';` のように書きます。

## Node.js・Python {#node-python}

管理画面はNode.js・Python・Javaのコードも生成します。どれもその言語の標準ライブラリだけを使います。以下はNode.js(18以上)のコードです。

```js
// サーバーでのみ実行してください。トークンをブラウザに送ってはいけません。
import { createHmac } from 'node:crypto';

const UCHAT_ROOM = 'my-room';
const UCHAT_TOKEN = 'チャットルームのトークン'; // 絶対に公開しないでください

export function uchatUserData(user, now = Math.floor(Date.now() / 1000)) {
	const fields = Object.entries({ ...user, room: UCHAT_ROOM, time: now })
		.filter(([, v]) => v !== undefined && v !== null && v !== '')
		.map(([k, v]) => [k, String(v)])
		.sort(([a], [b]) => (a < b ? -1 : 1));
	const message = ['uchat-v2', ...fields.map(([k, v]) => `${k}=${Buffer.from(v, 'utf8').toString('hex')}`)].join('\n');
	const hash = createHmac('sha256', UCHAT_TOKEN).update(message, 'utf8').digest('hex');
	return fields
		.map(([k, v]) => `${k} ${encodeURIComponent(v)}`)
		.concat(`hash v2.${hash}`)
		.join('|');
}

// 例(Express): uchatUserData({ nick: req.user.nickname, id: req.user.id, level: req.user.level, auth: req.user.isAdmin ? 'admin' : '' })
```

Python(3.8以上):

```python
# サーバーでのみ実行してください。トークンをブラウザに送ってはいけません。
import binascii
import hashlib
import hmac
import time
from urllib.parse import quote

UCHAT_ROOM = 'my-room'
UCHAT_TOKEN = 'チャットルームのトークン'  # 絶対に公開しないでください


def uchat_user_data(user, now=None):
    data = dict(user, room=UCHAT_ROOM, time=int(time.time() if now is None else now))
    fields = sorted((k, str(v)) for k, v in data.items() if v is not None and str(v) != '')
    message = '\n'.join(['uchat-v2'] + ['%s=%s' % (k, binascii.hexlify(v.encode('utf-8')).decode('ascii')) for k, v in fields])
    digest = hmac.new(UCHAT_TOKEN.encode('utf-8'), message.encode('ascii'), hashlib.sha256).hexdigest()
    parts = ['%s %s' % (k, quote(v, safe="!~*'()")) for k, v in fields]
    return '|'.join(parts + ['hash v2.' + digest])
```

作った文字列をHTML属性に入れるときは、テンプレートエンジンのエスケープを通して `<u-chat room="my-room" user_data="...">` として出力します。一覧にない言語に移植する場合は、[会員連携の署名仕様](/ja/doc/3.7)の確認用サンプル値で結果を照合してください。

## Next.js・Nuxt・SvelteKit {#frameworks}

サーバーコードのあるフレームワークはサーバーで署名し、画面には署名結果だけを npm パッケージのコンポーネントに渡します。トークンはサーバーの環境変数(`.env`)に置き、ブラウザーに送りません。

1. サーバー(Next.js のサーバーコンポーネント、Nuxt の `server/api`、SvelteKit の `+page.server.js`)で、上の Node.js の署名関数でログイン中の会員の `userData` を作ります。
2. 画面で `@u-chat/react`・`@u-chat/vue`・`@u-chat/svelte` の `UchatRoom`(ページ内)または `UchatWidget`([フローティングウィジェット](/ja/doc/2.2#widget))に `userData` として渡します。

```svelte
<!-- +page.svelte: +page.server.js の load がサーバーで uchatUserData(...) で作った userData を渡します -->
<script>
	import { UchatRoom } from '@u-chat/svelte';

	let { data } = $props();
</script>

<UchatRoom room="my-room" userData={data.userData} width="100%" height={500} />
```

設置コード画面でサイトの種類を Next.js・Nuxt・SvelteKit にすると、署名関数・サーバーコード・画面コードをまとめて作れます。設置の形をフローティングウィジェットにすると、`UchatWidget` とウィジェット設定が props として入ります。

## user_data_urlで読み込む {#user-data-url}

ページのHTMLをキャッシュしたり静的ファイルとして書き出したりするサイトでは、署名をHTMLに入れるのが難しくなります。その場合は `user_data` の代わりに `user_data_url` に自分のサーバーのアドレスを入れます。

```html
<u-chat room="my-room" user_data_url="/uchat/user-data" style="display:inline-block; width:100%; max-width:500px; height:500px;"></u-chat>
```

- チャットルームは入室時にこのアドレスの後ろに `?room=room-id` を付け、`<script>` として読み込みます。そのため、アドレスにすでに `?` が含まれていてはいけません。
- レスポンスはJavaScriptで、実行されたときに `window.Uchat_cache.token[room-id]` に署名文字列を入れる必要があります。

Expressで上の `uchatUserData` を使って応答する例です。

```js
app.get('/uchat/user-data', (req, res) => {
	res.set({
		'Content-Type': 'application/javascript; charset=utf-8',
		'Cache-Control': 'no-store, private',
		'X-Content-Type-Options': 'nosniff'
	});
	// ほかのサイトがこのアドレスを<script>で読み込んで会員の署名を持ち出せないようにする
	if (req.get('Sec-Fetch-Site') === 'cross-site') return res.status(403).send('/* forbidden */');
	const user = req.user ? { nick: req.user.nickname, id: req.user.id, auth: req.user.isAdmin ? 'admin' : '' } : {};
	const room = JSON.stringify(UCHAT_ROOM);
	res.send(
		'window.Uchat_cache=window.Uchat_cache||{};window.Uchat_cache.token=window.Uchat_cache.token||{};' +
			`window.Uchat_cache.token[${room}]=${JSON.stringify(uchatUserData(user))};`
	);
});
```

レスポンスはログインセッションで会員を確認してリクエストごとに新しく署名し、`Cache-Control: no-store` でキャッシュされないようにします。ログインしていなければ空の会員情報で署名し、ゲストとして入室させます。注意点は[セキュリティチェックリスト](/ja/doc/2.6)にあります。

## 時刻連携 {#time-sync}

**会員連携**メニューの**時刻連携**(最初はオン)は、古い入室情報を拒否します。誰かがページの `user_data` をコピーしておき、あとで使うことを防ぎます。

- 署名に含まれる `time`(自分のサーバーのUnix秒)とuchatサーバーの現在時刻の差を記憶し、自動で追従します。自分のサーバーの時計が少しずれていても問題ありません。
- 新しい入室の差が記憶している差より300秒(5分)を超えて過去であれば、エラー205で拒否します。
- 同じ `user_data` は最初に使ってから5分間だけ受け付けます。再利用しても記憶している差は変わりません。
- **標準時刻に固定**をオンにすると、差を記憶せずにuchatサーバーの時刻と直接比較します。自分のサーバーの時計が正確な場合に使ってください。
- サーバーを移転した場合や、時計を5分を超えて戻した場合は、**時刻基準 → 初期化**を押します。
- オフにすると `time` を検査しないため、コピーした `user_data` をいつでも再利用できてしまいます。

時刻連携をオンにしている場合、`user_data` を含むページを5分を超えてキャッシュしてはいけません。キャッシュが必要なら `user_data_url` を使ってください。すでに入室している人は、接続が一時的に切れても、最初に入室してから4時間以内であればuchatが自動で再接続させます。

## ニックネームのルール {#nickname}

- `nick` は15文字で切ったあと、空白(スペース・タブ・改行)をすべて取り除きます。`山田 太郎` は `山田太郎` と表示されます。
- 空の場合はゲストのニックネームが自動で付きます。
- 同じニックネームがすでに接続している場合は、後ろに `_` とランダムな文字を付けて変更します。会員を区別するときはニックネームではなく `id` を使ってください。

## 入室できないとき {#troubleshooting}

| エラー | 原因と解決方法 |
| --- | --- |
| トークンがありません。(203) | 会員連携がオンのチャットルームに署名なしで入室しました。設置コードを連携コードに置き換え、ログインしていない訪問者にも署名した `user_data` を渡してください。 |
| トークンが変更されたようです。(202) | 署名が一致しません。トークンとチャットルームIDが正しいか、値のある項目を名前順で署名したか、[署名仕様](/ja/doc/3.7#fields)にない項目を入れていないか、値を `rawurlencode`(または `encodeURIComponent` と同じエンコード)で入れたかを確認します。 |
| ページを再読み込みしてください。(205) | 時刻連携の基準より5分を超えて古い入室情報か、同じ `user_data` を最初に使ってから5分が過ぎています。ページのキャッシュをオフにし、サーバーの時計を動かした場合は時刻基準を初期化してください。 |
| 入室パスワードが必要です。(701) | 入室パスワードを設定したチャットルームです。モデレーター以上はパスワードなしで入室できます。 |

管理ページの**会員連携**画面の下にある**入室記録**で、直近14日間の成功・失敗の回数と、直近の失敗50件(時刻、原因、入室したIP、リクエストに含まれていたID・ニックネーム、署名の時刻差)を確認できます。失敗はすべて回数に含まれますが、詳しい記録は1分あたり30件までを30日間だけ残します。失敗回数より記録が少ない日には**一部のみ記録**が付きます。

署名が正しいか自分で確認するには、[会員連携の署名仕様](/ja/doc/3.7)の確認用サンプル値を自分のコードに入れ、同じ文字列になるか比較してください。エラーはページスクリプトの `after.error` イベント(`data.code`)でも受け取れます。→ [イベント](/ja/doc/3.3)
