はじめに
株式会社ピー・アール・オーのアドベントカレンダー22日目は、
表題の通り、NextcloudとKeycloakを組み合わせてファイルサーバを構築したのでその手順を書き残す。
背景
ツーリング中にドラレコやGoProで撮影した動画を友人内で共有するにあたり、
SFTPを使用してファイル共有をしていた。
(1ファイル4GBくらいあるので、GoogleDriveはあっという間に容量超過した)
さすがにパスワード認証は論外なので公開鍵認証を使用していたところ、以下の運用上の問題が発生した。
- 秘密鍵を失くすケース(まあわかる)
- 接続手順を忘れるケース(彼は本当にエンジニアなのだろうか)
失くされる度に公開鍵を登録し直すのは億劫かつ、こんなことのために手順書を用意するのも馬鹿らしいので、
簡単に使えるファイルサーバを立ち上げることにした。
目的
SAML認証の目的
普段よく使うサービス、それこそGoogleアカウントなら忘れないだろうし、
忘れても自分でパスワード再発行してよしなにやってもらえるため。
本当はopenid connectでやりたかったが、外部idp(google)を挟むと認証後Nextcloudが例外吐くので断念。Keycloak単体だと普通に動くんだけど。。。
Nextcloudを使用する目的
オープンソースのオンラインストレージを検索すると引っかかったのはownCloudとNextcloudの2つ。どちらもDropboxライク。
残念なことにownCloudは有償版のみSAML対応であったため、Nextcloudを使用することにした。
詳しくは公式サイトでも見て下さい。
Keycloakを使用する目的
- 今後、思い付きで別サービスを立ち上げるかもしれないため、認証を統合したかった。
- Nextcloud上でID管理をしたくなかった。
Keycloakの詳細はこれとかを見てください。
環境
名称 | バージョンなど | 備考 |
---|---|---|
OS | Raspbian GNU/Linux 10 (buster) | ラズパイ4 |
Keycloak | 10.0.1 | ID管理/認証 |
Nextcloud | 18.0.4 | ファイルサーバ |
Postgresql | 12.1 | Nextcloud & Keycloak用DB |
nginx | 1.14.2 | リバプロ |
流れ
構築
各種ミドルウェアの準備
docker-composeはpipで入れた。
$ sudo apt-get install -y nginx
$ sudo curl -fsSL https://get.docker.com/ | sh
$ sudo apt-get -y install python3-dev python3-pip
$ sudo pip3 install docker-compose
Nextcloud、Keycloak、PostgresqlはDockerで入れた。
Keycloakはarmで動くいい感じのイメージが見つからなかったので、Dockerfile書いてビルドした。
$ docker build -t me/keycloak .
FROM balenalib/raspberry-pi-openjdk:8-stretch
ENV KEYCLOAK_VERSION 10.0.1
ENV JDBC_POSTGRES_VERSION 42.2.12
ENV JBOSS_HOME /data/keycloak
USER root
RUN mkdir /data
WORKDIR /data
RUN curl -LO https://downloads.jboss.org/keycloak/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz
RUN tar xfp keycloak-${KEYCLOAK_VERSION}.tar.gz --no-same-owner
RUN mv ${JBOSS_HOME}-${KEYCLOAK_VERSION} ${JBOSS_HOME}
RUN rm ${JBOSS_HOME}-${KEYCLOAK_VERSION}.tar.gz
RUN chmod +x ${JBOSS_HOME}/bin/standalone.sh
RUN mkdir ${JBOSS_HOME}/cli
RUN mkdir -p ${JBOSS_HOME}/standalone/deployments/
RUN curl -LO https://jdbc.postgresql.org/download/postgresql-${JDBC_POSTGRES_VERSION}.jar
COPY keycloak-add-user.json ${JBOSS_HOME}/standalone/configuration/keycloak-add-user.json
COPY config.cli ${JBOSS_HOME}/cli/config.cli
RUN sed -i -e "s/{{JDBC_POSTGRES_VERSION}}/${JDBC_POSTGRES_VERSION}/g" ${JBOSS_HOME}/cli/config.cli
RUN ${JBOSS_HOME}/bin/jboss-cli.sh --file=${JBOSS_HOME}/cli/config.cli
RUN rm -rf ${JBOSS_HOME}/standalone/configuration/standalone_xml_history
EXPOSE 8080
CMD [ "/bin/bash", "/data/keycloak/bin/standalone.sh", "-b", "0.0.0.0" ]
- 設定ファイルについて
設定ファイル名 | 設定内容 |
---|---|
config.cli | jboss-cliのコマンドを記述したファイル |
keycloak-add-user.json | adminユーザの作成 |
- config.cli
jboss-cliを実行し、各設定を行っている。
やっていることは以下。
- JDBCドライバのインストール
- DataSourceの設定
- Keycloakのhttpsリダイレクト設定(リバプロをかませるため)
- Keycloak管理コンソールのアクセス制御
ちなみに
CLIでmodule add
を実施するのは非推奨らしいが、今回はやっちゃう。
embed-server --server-config=standalone.xml --std-out=echo
module add --name=org.postgresql --resources=postgresql-{{JDBC_POSTGRES_VERSION}}.jar --dependencies=javax.api,javax.transaction.api
/subsystem=undertow/server=default-server/http-listener=default: write-attribute(name=proxy-address-forwarding, value=true)
/subsystem=datasources/data-source=KeycloakDS: remove()
/subsystem=datasources/data-source=KeycloakDS: add(jndi-name=java:jboss/datasources/KeycloakDS,enabled=true,use-java-context=true,use-ccm=true, connection-url=jdbc:postgresql://${env.DB_ADDR:postgres}:5432/${env.DB_DATABASE:keycloak}${env.JDBC_PARAMS:}, driver-name=postgresql)
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=user-name, value=${env.DB_USER:keycloak})
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=password, value=${env.DB_PASSWORD:password})
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=check-valid-connection-sql, value="SELECT 1")
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=background-validation, value=true)
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=background-validation-millis, value=60000)
/subsystem=datasources/data-source=KeycloakDS: write-attribute(name=flush-strategy, value=IdleConnections)
/subsystem=datasources/jdbc-driver=postgresql:add(driver-name=postgresql, driver-module-name=org.postgresql, driver-xa-datasource-class-name=org.postgresql.xa.PGXADataSource)
/subsystem=keycloak-server/spi=connectionsJpa/provider=default:write-attribute(name=properties.schema,value=${env.DB_SCHEMA:public})
/subsystem=undertow/configuration=filter/expression-filter=ipAccess:add(,expression="path-prefix[/auth/admin] -> ip-access-control(acl={'192.168.0.0/24 allow'})")
/subsystem=undertow/server=default-server/host=default-host/filter-ref=ipAccess:add()
stop-embedded-server
- keycloak-add-user.json
[ {
"realm" : "master",
"users" : [ {
"username" : "admin",
"enabled" : true,
"credentials" : [ {
"type" : "password",
"secretData" : "{\"value\":\"3MLtOqxjN3tKhaXmIwSQhTLBCdWHf1FvsdeHibw49glSj5Gj7L/8Dac7P399mJlpsOJ/20Wr3OmWBA+E/BzqhA==\",\"salt\":\"eLxa1xVzxfsGu7qlYsZosg==\"}",
"credentialData" : "{\"hashIterations\":100000,\"algorithm\":\"pbkdf2-sha256\"}"
} ],
"realmRoles" : [ "admin" ]
} ]
} ]
起動はdocker-composeを使用する。
全部同じネットワークに乗っけて、ローカルからのみアクセスOKとしている。
また、コンテナ立ち上げ時にNextcloud、KeycloakのDBをそれぞれ作成するため、initdb
ディレクトリにSQLを放り込んでおく。
見よう見まねで書いているので、質についてはなんともいえない。
version: '3'
services:
postgres:
image: postgres
container_name: 'postgres'
volumes:
- ./postgres_data:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
restart: always
network_mode: bridge
nextcloud:
image: nextcloud
container_name: 'nextcloud'
volumes:
- ./nextcloud:/var/www/html
ports:
- "127.0.0.1:10443:443"
- "127.0.0.1:10080:80"
restart: always
environment:
- POSTGRES_HOST=postgres
- POSTGRES_DB=nextcloud
- POSTGRES_USER=nextcloud
- POSTGRES_PASSWORD=password
- NEXTCLOUD_ADMIN_USER=admin
- NEXTCLOUD_ADMIN_PASSWORD=password
- NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
- OVERWRITEHOST=cloud.example.com
- OVERWRITEPROTOCOL=https
links:
- postgres
network_mode: bridge
keycloak:
image: me/keycloak
container_name: 'keycloak'
volumes:
- ./deployments:/data/keycloak/standalone/deployments/
restart: always
links:
- postgres
ports:
- 127.0.0.1:8080:8080
network_mode: bridge
- initdbに格納するSQL
CREATE USER keycloak WITH PASSWORD 'password';
CREATE DATABASE keycloak OWNER keycloak;
CREATE USER nextcloud WITH PASSWORD 'password';
CREATE DATABASE nextcloud OWNER nextcloud;
リバースプロキシの設定
Dockerにプロキシする設定。
以下のようなconfファイルを作成し、/etc/nginx/conf.d
に放り込む。
http ⇒ httpsのリダイレクトは省略。
### nextcloud ###
server {
listen 443;
client_max_body_size 10G;
server_name cloud.example.com;
ssl on;
ssl_certificate /etc/nginx/cert/privatekey.pem;
ssl_certificate_key /etc/nginx/cert/server.crt;
location / {
proxy_pass http://localhost:10080;
proxy_redirect http:// https://;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
### keycloak ###
server {
listen 443;
server_name sso.example.com;
ssl on;
ssl_certificate /etc/nginx/cert/privatekey.pem;
ssl_certificate_key /etc/nginx/cert/server.crt;
location / {
proxy_pass http://localhost:8080;
proxy_redirect http:// https://;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
SAML署名鍵 & 証明書の作成
SAMLで使用する鍵と証明書を作成しておく。
DN項目はCN含めすべて空でよい。
$ openssl req -nodes -new -x509 --days 10000 -keyout private.key -out public.crt
起動
この後の設定は各システムのGUIで行うので立ち上げる。
$ docker-compose up -d
Keycloakの設定 - レルム作成
https://sso.example.com/auth
にアクセスすると、ウェルカムページが表示される。
Administration Console
を選択し、管理コンソールにログインする。
レルムの作成
レルムの追加から、nextcloud
レルムを作成する。
レルム証明書の取得
レルムの設定 ⇒ 鍵
から、アルゴリズムRS256
の証明書を表示し、コピーしておく。
Nextcloudの設定
AdminユーザでNextcloudにログインし、右上のアイコンからアプリ
⇒ 連携
から SSO & SAML authentication
をインストールする。
SSOとSAML認証の設定
右上のアイコンから設定
⇒ 左メニューのSSOとSAML認証
から設定画面に入り、
Identity providerを追加する
を押して設定を作成する。
設定内容は以下。
- 一般
設定項目 | 設定内容 |
---|---|
UIDをマップする属性 | username |
IPプロバイダオプションの表示名 | お好みで |
- Service Providerデータ
設定項目 | 設定内容 |
---|---|
名前IDの形式 | 指定なし |
サービスプロバイダのX.509 証明書 | 先ほど作ったpublic.crt をそのまま貼り付ける |
サービスプロバイダーの秘密鍵 | 先ほど作ったprivate.key をそのまま貼り付ける |
- Identity Providerデータ
設定項目 | 設定内容 |
---|---|
IdPエンティティの識別子 | https://Keycloakのホスト名/auth/realms/レルム名 |
SPが認証要求メッセージをを送信するIdPのURIターゲット | https://Keycloakのホスト名/auth/realms/レルム名/protocol/saml |
- オプションのIdentity Provider設定
設定項目 | 設定内容 |
---|---|
SPが認証要求メッセージをを送信するIdPのURIターゲット | https://Keycloakのホスト名/auth/realms/レルム名/protocol/saml |
IdPの公開X.509証明書 |
-----BEGIN CERTIFICATE----- さっきKeycloak上で控えた証明書-----END CERTIFICATE-----
|
- 属性マッピング
設定項目 | 設定内容 |
---|---|
表示名をにマップする属性(原文ママ) | username |
電子メールアドレスをマップする属性 |
- セキュリティ設定
以下にチェックを入れる。
設定項目 |
---|
このSPによって送信された samlp:AuthnRequest メッセージが署名されるかどうかを示します。[SPのメタデータがこの情報を提供する] |
このSPによって送信された samlp:logoutRequest メッセージが署名されるかどうかを示します。 |
このSPによって送信された samlp:logoutResponse メッセージが署名されるかどうかを示します。 |
このSPが受信したsamlp:Response、samlp:LogoutRequest、およびsamlp:LogoutResponse要素が署名されるための要件を示します。 |
ここのSPによって受信されたsaml:Assertion要素が署名されるための要件を示します。 [SPのメタデータはこの情報を提供する] |
設定完了後、最下部のボタンから、メタデータXML
をダウンロードする。
Keycloakの設定 - クライアント設定
クライアントの作成
SAML用クライアントを新規作成する。
ここで、インポート
を選択し、先ほどNextcloud上でダウンロードしたメタデータXML
を選択する。
すると、以下画面のように設定項目が自動的に投入される。
クライアントの設定
クライアントを保存すると設定画面に移動するため、そのまま設定する。
設定
以下の設定項目のみ修正する。
設定項目 | 設定内容 |
---|---|
ルートURL | https://Nextcloudのホストネーム |
有効なリダイレクトURL | https://Nextcloudのホストネーム/* |
マッパー
Nextcloudユーザーはusername
とemail
が必須属性なのでマッパーも設定する。
- マッパー
名前 | マッパータイプ | プロパティ | Friendly Name | SAML Attribute Name | SAML Attribute NameFormat |
---|---|---|---|---|---|
username | User Property | username | username | username | Basic |
User Property | Basic |
クライアントスコープの設定
左メニューのクライアントスコープ
からrole_list
を選択 ⇒ マッパー
からrole_list
を選択 ⇒ Single Role Attribute
をオン
にしておく。
この設定を入れないと、SAML認証時、Nextcloudが謎の例外を吐いてしまい認証に失敗する。
外部idp設定
Googleの設定
Googleアカウントによる認証を行うには、まず、GoogleのAPIコンソールから認証情報を作成する。
Keycloakの設定
左メニューからアイデンティティプロバイダーを選択し、Google
を選択し、設定画面に入る。
- 設定内容(差分のみ)
設定項目 | 設定内容 |
---|---|
クライアントID | さっき控えたクライアントID |
クライアントシークレット | さっき控えたクライアントシークレット |
デフォルトスコープ | openid profile email |
拡張モジュール作成/設定
実はここまでの設定でもうNextcloudにGoogleアカウントでログインすることができる。
ただ、このままではGoogleアカウント持ってれば誰でもウェルカム状態なので、
Keycloakに拡張認証モジュールを投入し、Keycloak上にアカウントが存在しない人をたたき出すようにする。
今回拡張したいのはFirst Broker Loginなので、AbstractIdpAuthenticator
を継承してモジュールを作成した。
認証モジュールの流れ
- Google経由のログインが初めてなら拡張モジュールに入る
- Googleから送られてきたユーザー情報のメールアドレスで、Keycloakにアカウントが作られているかチェック
- 作られていなければ認証NGにしてたたき出す
- 作られていたらGoogleとアカウントリンクして認証OKにする
ファイル構成
認証モジュールの作成には、少なくとも認証モジュール本体、ファクトリー、クラス登録用定義ファイルの3種が必要。
コード
- 認証モジュール本体
package local.example;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import java.util.Objects;
public class IpdAccountExistsAuthenticator extends AbstractIdpAuthenticator {
private static Logger logger = Logger.getLogger(IpdAccountExistsAuthenticator.class);
/**
* 認証処理
*
* @param context
* @param serializedCtx
* @param brokerContext
*/
@Override
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
logger.info("Start IpdAccountExistsAuthenticator");
// ユーザがKeycloakに登録済みであればOK
UserModel user = context.getSession().users().getUserByUsername(brokerContext.getEmail(), context.getRealm());
if (Objects.isNull(user)) {
// ユーザが取れなければ認証失敗にする
throw new AuthenticationFlowException("そんなユーザいません : " + brokerContext.getEmail(), AuthenticationFlowError.UNKNOWN_USER);
}
user.setEnabled(true);
user.setEmail(brokerContext.getEmail());
user.setFirstName(brokerContext.getFirstName());
user.setLastName(brokerContext.getLastName());
logger.info("認証OK : " + user.getUsername());
// 認証成功
context.setUser(user);
context.getAuthenticationSession().setAuthNote(BROKER_REGISTERED_NEW_USER, "true");
context.getAuthenticationSession().setAuthenticatedUser(user);
context.success();
}
@Override
protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext
serializedCtx, BrokeredIdentityContext brokerContext) {
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return false;
}
}
- ファクトリー
package local.example;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
public class IpdAccountExistsAuthenticatorFactory implements AuthenticatorFactory {
public static final String ID = "exists-account-from-keycloak";
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED};
@Override
public Authenticator create(KeycloakSession keycloakSession) {
return new IpdAccountExistsAuthenticator();
}
@Override
public void init(org.keycloak.Config.Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return ID;
}
@Override
public String getReferenceCategory() {
return "exists-account";
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public String getDisplayType() {
return ID;
}
@Override
public String getHelpText() {
return "ipd exists accoount";
}
public boolean isUserSetupAllowed() {
return true;
}
public List<ProviderConfigProperty> getConfigProperties() {
return null;
}
}
- クラス登録用定義ファイル
local.example.IpdAccountExistsAuthenticatorFactory
ビルド
上記ファイルをビルドしてjarを生成する。
pomはこんな感じにしてmvn install
した。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>club.honatsugi-ol.sso</groupId>
<version>1.0-SNAPSHOT</version>
<name>IDP Exists</name>
<description/>
<artifactId>idp_account_exists</artifactId>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<keycloak.version>10.0.1</keycloak.version>
<version.wildfly.maven.plugin>1.1.0.Final</version.wildfly.maven.plugin>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<version>3.4.1.Final</version>
</dependency>
</dependencies>
<build>
<finalName>idp-account-exists</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Dependencies>org.keycloak.keycloak-services</Dependencies>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
デプロイ
Keycloakのホームディレクトリ/standalone/deployments
に産み出されたjarを放り込むと、ホットデプロイしてくれる。
認証フローの作成
左メニューの認証
から、以下のような認証フローを作成する。
設定が複雑なので、デフォルトで存在するフローをコピー ⇒ アクション
からExecution全削除 ⇒ Executionを追加
から拡張モジュールを追加とすると楽。
作成したフローの設定
左メニューのアイデンティティプロバイダー
からさっき作ったgoogle
を選択し、
初回ログインフロー
にフローを設定する。
動作確認
ログイン成功(登録済みユーザ)
ログイン失敗(未登録ユーザ)
メモ
NextcloudのSSOとSAML認証
の設定の保存場所
設定はNextcloudDBのoc_appconfig
テーブルに存在する。
appid
カラムをuser_saml
で絞ると見つけるのが楽。
ガチャガチャいじってたらデータが壊れ、設定が保存されなくなってしまったが、レコード修正したら治った。
Keycloakのリモートデバッグ
standalone.conf
にこんな設定があるので、コメント解除してKeycloakを再起動してやればOK。
ポート開けてやればIDEから接続可能。
# Sample JPDA settings for remote socket debugging
#JAVA_OPTS="$JAVA_OPTS -agentlib:jdwp=transport=dt_socket,address=8787,server=y,suspend=n"
ports:
- 127.0.0.1:8080:8080
- 8787:8787
終わりに
ラズパイ4でもなかなか機敏に動く。感動した。
もともとこのシステムはラズパイ2で無理矢理動かしていたが、サムネ生成の速度とか雲泥の差。
記事書くついでに移植したが、やってよかった。。。