# 회원 연동

> 내 사이트에 로그인한 회원의 닉네임·아이디·레벨·권한으로 채팅방에 들어오게 하는 흐름과, 접속이 안 될 때 확인할 것을 설명해요.

회원 연동을 쓰면 내 사이트 회원이 사이트의 닉네임·아이디·레벨·권한 그대로 채팅방에 들어와요. 내 서버가 접속 정보에 채팅방 토큰으로 서명하고, 유챗은 서명이 맞는 접속만 받으므로 다른 사람의 닉네임을 흉내 낼 수 없어요.

## 동작 흐름 {#how-it-works}

1. 회원이 내 사이트 페이지를 열어요.
2. 내 서버가 로그인한 회원 정보(`nick`, `id`, `level`, `auth` 등)와 채팅방 아이디, 현재 시각을 모아 채팅방 토큰으로 서명한 문자열을 만들어요.
3. 그 문자열을 `<u-chat user_data="...">`에 넣어 페이지를 내려 보내요.
4. 채팅 서버가 서명을 확인하고, 맞으면 그 정보로 입장시켜요.

토큰은 서명을 만드는 데만 쓰고 페이지에는 나가지 않아요. 서명 문자열을 만드는 규칙과 필드 전체는 [회원 연동 서명 규격](/doc/3.7)이 정본이에요.

## 켜는 순서 {#enable}

1. 관리 페이지 **회원 연동** 메뉴에서 **회원 연동**을 켜요.
2. **설치 코드** 메뉴에서 **사이트 종류**(PHP, Node.js, Python, Java, 그누보드 등)를 고르고 **코드 만들기**를 눌러요. 이 코드에는 채팅방 토큰이 들어 있어요.
3. 코드의 회원 정보 변수를 내 사이트에 맞게 바꿔 서버 코드에 넣어요.

회원 연동을 켜는 순간부터 서명 없는 접속은 거절돼요(오류 203). 사이트 코드를 먼저 바꿔 둔 뒤 켜는 편이 안전해요. 로그인하지 않은 방문자도 서명해야 해요 → [로그인하지 않은 방문자](#guests). 그누보드·워드프레스 같은 CMS는 [5분 설치](/doc/1.2#cms)를 보세요.

## 로그인하지 않은 방문자 {#guests}

회원 연동이 켜진 채팅방은 **모든 접속**에 서명이 있어야 해요. 로그인하지 않은 방문자에게도 `user_data`를 넣어야 하고, 빼면 오류 203으로 들어오지 못해요.

- 닉네임·아이디 없이 채팅방 아이디와 시각만 서명하면 손님으로 들어와요. 손님 닉네임은 자동으로 붙어요.
- 아래 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'] = $닉네임변수;
$joinData['id'] = $아이디변수;
$joinData['level'] = $레벨변수;
$joinData['auth'] = ''; // admin, subadmin, member, guest 중 하나. 비워 두면 아이디가 있으면 회원, 없으면 손님
$joinData['icons'] = $아이콘주소변수;
?>
<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="...">`로 출력해요. 목록에 없는 언어로 옮길 때는 [회원 연동 서명 규격](/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`([떠 있는 위젯](/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=채팅방아이디`를 붙여 `<script>`로 불러와요. 그래서 주소에 이미 `?`가 들어 있으면 안 돼요.
- 응답은 자바스크립트이고, 실행되면 `window.Uchat_cache.token[채팅방아이디]`에 서명 문자열을 넣어야 해요.

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`로 캐시되지 않게 해요. 로그인하지 않았으면 빈 회원 정보로 서명해 손님으로 들어오게 해요. 주의점은 [보안 체크리스트](/doc/2.6)에 있어요.

## 시간 연동 {#time-sync}

**회원 연동** 메뉴의 **시간 연동**(처음엔 켜져 있음)은 오래된 접속 정보를 거절해요. 누군가 페이지의 `user_data`를 복사해 두었다가 나중에 쓰는 것을 막아요.

- 서명에 들어간 `time`(내 서버의 Unix 초)과 유챗 서버 현재 시각의 차이를 기억해 알아서 따라가요. 내 서버 시계가 조금 틀려도 돼요.
- 새 접속의 차이가 기억한 차이보다 300초(5분) 넘게 과거면 오류 205로 거절해요.
- 같은 `user_data`는 처음 쓴 뒤 5분 동안만 받아요. 다시 써도 기억한 차이는 바뀌지 않아요.
- **표준시간 고정**을 켜면 차이를 기억하지 않고 유챗 서버 시각과 바로 비교해요. 내 서버 시계가 정확할 때 쓰세요.
- 서버를 옮기거나 시계를 뒤로 5분 넘게 바꿨다면 **시간 기준 → 초기화**를 눌러요.
- 끄면 `time`을 검사하지 않아 복사한 `user_data`를 언제든 다시 쓸 수 있어요.

시간 연동을 켜면 `user_data`가 든 페이지를 5분 넘게 캐시하면 안 돼요. 캐시가 필요하면 `user_data_url`을 쓰세요. 이미 들어온 사람은 연결이 잠깐 끊겨도 처음 들어온 뒤 4시간 안에는 유챗이 알아서 다시 접속시켜요.

## 닉네임 규칙 {#nickname}

- `nick`은 15자로 자른 뒤 공백(스페이스·탭·줄바꿈)을 모두 지워요. `홍 길동`은 `홍길동`으로 보여요.
- 비어 있으면 손님 닉네임을 자동으로 붙여요.
- 같은 닉네임이 이미 접속해 있으면 뒤에 `_`와 임의 문자를 붙여 바꿔요. 회원을 구분할 때는 닉네임이 아니라 `id`를 쓰세요.

## 접속이 안 될 때 {#troubleshooting}

| 오류 | 원인과 해결 |
| --- | --- |
| 토큰이 누락됐어요. (203) | 회원 연동이 켜진 채팅방에 서명 없이 접속했어요. 설치 코드를 연동 코드로 바꾸고, 로그인하지 않은 방문자에게도 서명한 `user_data`를 넣으세요. |
| 토큰이 변경된 것 같아요. (202) | 서명이 맞지 않아요. 토큰과 채팅방 아이디가 맞는지, 값이 있는 항목을 이름순으로 서명했는지, [서명 규격](/doc/3.7#fields)에 없는 항목을 넣지 않았는지, 값을 `rawurlencode`(또는 `encodeURIComponent`와 같은 인코딩)로 넣었는지 확인해요. |
| 페이지를 새로고침해 주세요. (205) | 시간 연동 기준보다 5분 넘게 오래된 접속 정보이거나, 같은 `user_data`를 처음 쓴 뒤 5분이 지났어요. 페이지 캐시를 끄고, 서버 시계를 옮겼다면 시간 기준을 초기화하세요. |
| 접속 비밀번호가 필요해요. (701) | 접속 비밀번호를 건 채팅방이에요. 부관리자 이상은 비밀번호 없이 들어와요. |

관리 페이지 **회원 연동** 화면 아래 **입장 기록**에서 최근 14일 동안의 성공·실패 횟수와 최근 실패 50건(시각, 원인, 접속한 IP, 요청에 실려 온 아이디·닉네임, 서명 시각 차이)을 볼 수 있어요. 실패는 모두 횟수에 들어가고, 자세한 기록은 1분에 30건까지만 30일 동안 남아요. 실패 횟수보다 기록이 적은 날에는 **일부만 기록됨**이 붙어요.

서명이 맞는지 직접 확인하려면 [회원 연동 서명 규격](/doc/3.7)의 확인용 예시 값을 내 코드에 넣어 같은 문자열이 나오는지 비교하세요. 오류는 페이지 스크립트의 `after.error` 이벤트(`data.code`)로도 받을 수 있어요. → [이벤트](/doc/3.3)
