LoginSignup
75
84

More than 3 years have passed since last update.

OpenLDAPで社内サービスのユーザ情報を一元管理する

Last updated at Posted at 2018-03-02

Yet another OpenLDAP チュートリアルです。

以下のバージョンを対象に設定例を書きます。

ソフトウェア バージョン この記事の設定例で使用するポート 備考
OpenLDAP 2.4.44 389
GitBucket 4.20.0 8081
Apache 2.4.29 8083
Subversion 1.9.7 (r1800392) 8084
Redmine 3.4.4.stable 8085
Rocket.Chat 0.62.0 8086
Knowledge 1.12.0 8082
Wekan 2.13 (commit-id: 6f0e074) 8087 ※ 2019/2/2 追記

目的

  • グループ内で利用しているWebサービスが増えてきたので、ユーザ管理が面倒臭くなって来た
  • LDAPでユーザを一元管理しよう
  • 既存のActiveDirectory (Windows)を弄るのは全社的に影響があるので、グループ内にOpenLDAPを立てて、グループ内サービスの認証はこれを使おう
  • 知見が溜まったら、ActiveDirectoryも試して完全な一元管理にしたいね

私はLDAPに関してはほとんど何もわからないが、なんとかOpenLDAPでの一元管理が出来そうな見込みが立ったのでここにその記録を残すことにした。LDAPに関する詳しい説明をするわけではない。とにかく構築して利用したいのである。

バックアップとか冗長化とかセキュア通信とかは他の記事に譲る。ここでは触れない。

LDAPの用語

属性 説明
dc ドメイン構成要素(Domain Component)
ou 組織単位(Organization Unit)
cn 一般名(Common Name)

まず、このあたりの用語が非常に取っつきにくい。略語すぎて区別がつきにくいと言うべきか。だから、混乱を避けるためこれを必要最低限とし、DNとかDITとか上記以外の用語は説明文中では使わないことにした。

LDAPの構成

dcとかouがなんなのか、既存の自組織の要素と照らし合わせて整理する。

既存要素 通常の表記 LDAPでの表記
社内ドメイン hoge.com dc=hoge,dc=com
XXX部 ou=XXX,dc=hoge,dc=com
YYY課 ou=YYY,ou=XXX,dc=hoge,dc=com
社員A Aさん cn=userA,ou=YYY,ou=XXX,dc=hoge,dc=com

例えば、Aさんのメールアドレスはこの例だと userA@hoge.com となるだろう。
それとは別に組織的な階層をouであらわしている。dcとouが別々の概念で現れる理由はよくはわからないが、(別にYYY.XXX.hoge.com というドメインがあっても良いわけだが)ドメインはコンピュータ上の構成の都合(メールアドレスとかコンピュータ名とか)で、それとは独立して実組織の都合を反映したいためにouがあるのではないかと思う。(本当のところは知らない)

hoge.comが既存のドメインだとして、今回の目的はそれと重複しないようにOpenLDAPを構築したい。通常なら、既存のドメインに従属するようにグループ内のドメインを fuga.hoge.com (dc=fuga,dc=hoge,dc=com) とかにするのが良いのかもしれない。

ただ、今回は閉じたネットワーク内でユーザ認証を一元管理したいだけなので、以下のように完全に独立した単純な構成とする。

識別名
ドメイン dc=hoge,dc=local
所属 ou=Users,dc=hoge,dc=local
cn=userA,ou=Users,dc=hoge,dc=local

ドメインは、もっと単純にdc=localだけでもいいし、実際の運用はそうしている。ただ、階層のある組織の例を示した方が設定の記述の例として有効だと思うのでこのようにした。
ouとcnの違いもわかりにくいが、ファイル構造で考えれば、ouはディレクトリ、cnはファイルと考えればわかりやすいのではないかと思う。ouは階層構造とcnの集合を表し、cnはデータを保持する。

また、cnはクラスを持っていてクラスによってどのようなデータ(メールアドレスとか姓名とか)を持つかがあらかじめ決まっていたりする。この辺りは実例のなかで扱う。

OpenLDAP を立ち上げる

この記事では OpenLDAP(以下、openldapと書く)やそれを利用するサービスをDockerを使って構築する。
openldap は、osixia/openldapのイメージを使うこととした。

まず、私のホームディレクトリにdocker/openldapディレクトリを作成し、その配下に以下のファイルを作る

$HOME
`-- docker
    `-- openldap
        `-- docker-compose.yml
docker-compose.yml
version: '2'

services:
  app:
    image: osixia/openldap
    container_name: openldap
    environment:
      LDAP_ORGANISATION: "HogeHoge Group"
      LDAP_DOMAIN: "hoge.local"
      LDAP_ADMIN_PASSWORD: "changeme"
      LDAP_CONFIG_PASSWORD: "changeme2"
    ports:
      - "389:389"
    volumes:
      - ./db:/var/lib/ldap
      - ./slapd.d:/etc/ldap/slapd.d
      - ./ldif:/ldif

  app2:
    image: osixia/phpldapadmin
    container_name: phpldapadmin
    environment:
      PHPLDAPADMIN_LDAP_HOSTS: openldap
      PHPLDAPADMIN_HTTPS: "false"
    ports:
      - "8080:80"
    depends_on:
      - app

dockerでLDAP Serverを起動。ついでにphpLDAPadminもを参考に、phpLDAPadminも立ち上げるようにした。しかし、phpLDAPadminは決して使いやすいものではないので、手動でエントリを編集したい時や削除したいときとかにしか使ってない。

構築及び起動の手順は以下の通り。

$ cd ~/docker/openldap
$ docker-compose up

これで、起動に成功すると db, slapd.d ディレクトリが作成され、そこにファイルができる。別のシェルを立ち上げて以下のように各ディレクトリの中身を見るとファイルが出来ていることが確認できる。

$HOME
`-- docker
    `--openldap
        |-- db/
        |-- docker-compose.yml
        |-- ldif/
        `-- slapd.d/
$ ls db
DB_CONFIG        __db.002         alock            dn2id.bdb        entryUUID.bdb    log.0000000001
__db.001         __db.003         cn.bdb           entryCSN.bdb     id2entry.bdb     objectClass.bdb

$ ls slapd.d/
cn=config/                            cn=config.ldif                        docker-openldap-was-started-with-tls

db, slapd.dはそれぞれ、DBの構成ファイル(デフォルトではBerkeleyDB)と設定ファイルの内容になっており、コンテナを作り直してもデータの永続化がこのディレクトリで行えている。ldif というディレクトリもできているが、これについては後述する。
もし、いろいろと設定を行ったあとに最初からやり直したい場合は

$ docker-compose stop
$ docker-compose rm
$ rm -rf db slapd.d # 場合によっては、sudo rm …としないとダメかも
$ docker-compose up

とすればよい。

ou(所属) と cn(人)の追加

最初に示したように、ou=Users グループと、cn=userA ユーザのデータを追加してみる。

識別名
所属 ou=Users,dc=hoge,dc=local
cn=userA,ou=Users,dc=hoge,dc=local

ou=Users を作成するには、まず先ほど作成された ldif ディレクトリに以下のファイルを作成する。

ldif/ou-users.ldif
dn: ou=Users,dc=hoge,dc=local
objectClass: organizationalUnit

そして、以下のコマンドを実行する。オプションの細かな説明はこの記事の最後にまとめてあるので適宜参照して欲しい。

$ ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/ou-users.ldif

adding new entry "ou=Users,dc=hoge,dc=local"

Mac は標準で ldapadd コマンドを持っているので、このようにホスト側でそのまま実行できる。しかし、そのような環境でない場合はコンテナで実行する必要がある。コンテナで実行するには以下のようにすればよい。

$ docker exec -it openldap bash
# ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/ou-users.ldif

または、

$ echo "ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/ou-users.ldif" | docker exec -i openldap bash

以降、コマンド実行はホスト側で実行するかのように記述するが環境に合わせて読み替えて欲しい。
ldif ディレクトリは、ホストとコンテナ両方でコマンドが実行できるようファイル置き場をここに決めているというだけである。本当はなんでも良い。(ldif ディレクトリは、docker-compose.ymlファイルにて、コンテナの/ldif にマップするよう定義してあるので双方で参照できる)

検索してみる。正しく追加されている。

$ ldapsearch -LLL -D cn=admin,dc=hoge,dc=local -w changeme -b dc=hoge,dc=local

dn: ou=Users,dc=hoge,dc=local
objectClass: organizationalUnit
ou: Users

ou=Users 配下に userA を追加する

まずは、パスワードをhogehogeだとして、そのハッシュ値を得る。

$ slappasswd -h '{MD5}' -s hogehoge
{MD5}MpQ15eZr6AmmVq8QX0JAHg==

ここで、表示されたハッシュ値を以下のファイルの userPassword に貼り付ける。面倒なので以降この記事で設定するパスワードはこれを利用する。

ldif/cn-userA.ldif
dn: cn=userA,ou=Users,dc=hoge,dc=local
objectClass: inetOrgPerson
displayName: User A
sn: User
mail: usera@hoge.local
userPassword: {MD5}MpQ15eZr6AmmVq8QX0JAHg==
$ ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/cn-userA.ldif

sn は SirName で名前の姓を設定する属性である。この属性はinetOrgPersonクラスで必須として定義されてあるので何かしら書かなければならない。
mail は任意属性だが色々なWebサービスで利用されるので追加した。グループ内でメールを利用していない場合は必要ない。

追加したユーザを削除するには、追加した時に使ったLDIFファイルのうち、dn: の行の中身を指定する。(dn: を書かないことに注意)

$ ldapdelete -D cn=admin,dc=hoge,dc=local -w changeme
〜以下、キーボードからの入力(^D は、Ctrl+Dを押したことを示す)〜

cn=userA,ou=Users,dc=hoge,dc=local
^D

修正(エントリの部分的な変更)を行う方法は面倒くさいので、削除と追加で対応するか、phpldapadminを使うと良い。(phpldapadminについてはこの記事では説明しない)

ユーザの追加方法を示したが、利用するサービスによって必要な項目は変わってくる。以下、例としていろいろなユーザを作ってみる。

ユーザIDのみを持ったユーザの例

ldif/cn-userB.ldif
dn: uid=userB,ou=Users,dc=hoge,dc=local
objectClass: account
$ ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/cn-userB.ldif

dn の行が cn で始まらないことに注意。結局cnとはなんなのか・・・と思ってしまうが、accountクラスは低レベルな定義情報だからcnを持たないのだろうと思う。また、uidとuseridは同じ意味(useridはuidのエイリアス)として扱われる。
そのため(userid=userB)のようにフィルタを指定して検索しても、uid=userBのエントリを見つけることができる。

$ ldapsearch -LLL -D cn=admin,dc=hoge,dc=local -w changeme -b "ou=Users,dc=hoge,dc=local" "(userid=userB)"

dn: uid=userB,ou=Users,dc=hoge,dc=local
objectClass: account
uid: userB

ユーザID、パスワードを持ったユーザの例

objectClass を複数書いた例、accountクラスはuidを持ち、simpleSecurityObjectクラスはuserPasswordを持っている。

ldif/cn-userC.ldif
dn: uid=userC,ou=Users,dc=hoge,dc=local
objectClass: account
objectClass: simpleSecurityObject
userPassword: {MD5}MpQ15eZr6AmmVq8QX0JAHg==
ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/cn-userC.ldif

ユーザID、パスワードを持ったユーザの例2

ldif/cn-userD.ldif
dn: cn=userD,ou=Users,dc=hoge,dc=local
objectClass: person
objectClass: simpleSecurityObject
sn: user
userPassword: {MD5}MpQ15eZr6AmmVq8QX0JAHg==

なんとなく、cnを使いたいと思ったので、personクラスを使ってみた例。これもsn(姓)が必須になってしまう。あと、さらに電話番号などもう少し任意の属性がほしいと思ったらorganizationalPersonクラスというのもある。これは、personを継承している。

ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/cn-userD.ldif

ユーザID、パスワード、メールアドレスを持ったユーザの例

inetOrgPersonクラスを使うのが良いだろう。これは、organizationalPersonクラスを継承している。なお、この例となるユーザは定義済みのuserAであるが、以下の例は下の名前(givenName)も追加してさらに漢字の名前にしてみた。漢字はUTF-8で書くと良い。

dn: cn=userA,ou=Users,dc=hoge,dc=local
objectClass: inetOrgPerson
displayName: 愛 上雄
sn: 愛
givenName: 上雄
mail: usera@hoge.local
userPassword: {MD5}MpQ15eZr6AmmVq8QX0JAHg==

UNIXアカウントの例

dn: cn=userE,ou=Users,dc=hoge,dc=local
objectClass: account
objectClass: posixAccount
uid: userE
uidNumber: 1000
gidNumber: 1000
homeDirectory: /home/userE
userPassword: {MD5}MpQ15eZr6AmmVq8QX0JAHg==
loginShell: /bin/bash
gecos: user E
description: 説明

posixAccountクラスは単体ではcnを作れない補助用のクラスとして定義されているので、accountと組み合わせて使っている。

gecos は、歴史的にコメントやフルネームを書くフィールドとして使われてきた。descriptionはUNIXアカウントが持つ情報とは別にLDAPとして説明欄を持つためにあると思われる。

UNIXアカウントは作るがログインさせたくない場合は loginShell を/bin/falseにすれば良い。

Sambaユーザ

(調べてから追記する予定)

各サービスのLDAP設定例

以降の設定例では、cnをユーザIDとするので、userA,userD,userEだけが使える。userBのような例も設定を変えれば使えるかもしれないが試してはいない。

各サービスのコンテナからopenldapコンテナに直接接続するような設定をしても良いが、設定が冗長になるのでこの記事では行わない。このため設定にホストのIPアドレスを直接書かなくてはならない。

ホストのIPアドレスを埋め込みたくない場合は、openldapと同一のdocker-compose.ymlにサービスの定義を記述するか、docker-compose で別の docker-compose.yml で作ったコンテナとリンクする (ネットワークを繋げる)を参考にすると良さそう。

GitBucket

GitBucket

docker-compose.yml
version: '2'

services:
  app:
    container_name: gitbucket
    image: takezoe/gitbucket
    ports:
      - "8081:8080"
      - "29418:29418"
    volumes:
      - ./gitbucket:/gitbucket

  db:
    container_name: postgres
    image: postgres:9.5-alpine
    environment:
      POSTGRES_DB: gitbucket
      POSTGRES_USER: gitbucket
      POSTGRES_PASSWORD: gitbucket
    volumes:
      - ./db:/var/lib/postgresql/data
$ docker-compose up
項目
URL http://localhost:8081/
デフォルトの管理者ユーザ root
デフォルトのパスワード root

管理者でログインし、右上のアカウントアイコンからSystem Administration -> System settings -> Authentication LDAP にチェックをつけ、以下の設定を入れる。

項目 設定値
LDAP host openLDAPを起動したホスト側のIPアドレス(gitbucketコンテナ内からの接続のため、localhost は使えない)
LDAP port 389
Bind DN cn=admin,dc=hoge,dc=local
Bind password changeme
Base DN ou=Users,dc=hoge,dc=local
User name attribute cn
Additional filter condition 空欄
Full name attribute displayName
Mail address attribute mail または cn
Enable TLS チェックしない
Enable SSL チェックしない

Gitbucket はメールアドレスが必須となる。userDのようなmail属性を持たないユーザだとログイン後メールアドレスの登録を促される。ただし、実際にメールアドレスの形式 (xxx@xxxx.xxx)である必要はない。そこで、グループ内でメールを使っていない場合は、Mail address attribute に、cn など適当な属性をあてると良い。

Apache

以降のSubversionの設定のために先にApacheの設定について書く

Apacheの場合、複数モジュールを活用しなければならないので順を追って確認していく。

まず、以下のファイルを作成し、認証のない単純な構成を作る。

docker-compose.yml
version: '2'

services:
  app:
    image: httpd:alpine
    volumes:
      - ./httpd.conf:/usr/local/apache2/conf/httpd.conf
      - ./htdocs:/usr/local/apache2/htdocs
    ports:
      - 8083:80

httpd.conf は、apache:alpine のhttpd.confからコメントを削り、必要最低限に絞り込んだ以下を例に使用する。

httpd.conf
ServerRoot "/usr/local/apache2"

Listen 80
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule autoindex_module modules/mod_autoindex.so
LoadModule dir_module modules/mod_dir.so

<IfModule unixd_module>
User daemon
Group daemon
</IfModule>

ServerAdmin you@example.com
<Directory />
    AllowOverride none
    Require all denied
</Directory>

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    Options Indexes FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

<IfModule dir_module>
    DirectoryIndex index.html
</IfModule>

<Files ".ht*">
    Require all denied
</Files>

ErrorLog /proc/self/fd/2
LogLevel warn

LogFormat "%h %l %u %t \"%r\" %>s %b" common
CustomLog /proc/self/fd/1 common

この状態で、htdocsディレクトリを作成し、dockerでサービスを起動する。

$ mkdir htdocs
$ docker-compose up

そして、http://localhost:8083/ を開いて空のディレクトリが参照できることを確認する。

次に、普通のファイル認証を試す。htpasswd コマンドで .htpasswd ファイルを作成し、それを認証に使用する。ユーザはuserF、パスワードはhogehogeとする。

$ htpasswd -nb userF hogehoge > htdocs/.htpasswd
$ chmod 600 htdocs/.htpasswd

そして、httpd.confに以下の修正を加える

--- httpd.conf.orig 2018-03-02 07:46:08.000000000 +0900
+++ httpd-file.conf 2018-03-02 08:08:00.000000000 +0900
@@ -7,6 +7,10 @@
 LoadModule autoindex_module modules/mod_autoindex.so
 LoadModule dir_module modules/mod_dir.so
 LoadModule authz_core_module modules/mod_authz_core.so
+LoadModule authn_core_module modules/mod_authn_core.so
+LoadModule authz_user_module modules/mod_authz_user.so
+LoadModule auth_basic_module modules/mod_auth_basic.so
+LoadModule authn_file_module modules/mod_authn_file.so

 <IfModule unixd_module>
 User daemon
@@ -23,7 +27,12 @@
 <Directory "/usr/local/apache2/htdocs">
     Options Indexes FollowSymLinks
     AllowOverride None
-    Require all granted
+
+    AuthType Basic
+    AuthBasicProvider file
+    AuthUserFile /usr/local/apache2/htdocs/.htpasswd
+    AuthName "password file authentication"
+    Require valid-user
 </Directory>

 <IfModule dir_module>

認証に関連するモジュールは以下の通り

モジュール名 説明
authz_core_module Requireの利用に必要
authn_core_module AuthTypeの利用に必要
authz_user_module Require valid-userの利用に必要
auth_basic_module AuthBasicProviderの利用に必要
authn_file_module AuthBasicProvider fileの利用に必要

再度、http://localhost:8083/ を開いてuserFでBasic認証されることを確認する。

基本的な設定が確認できたところで、LDAP認証を試す。
ApacheでLDAP認証をするには以下のモジュールが必要になる。

モジュール名 説明
mod_authnz_ldap LDAP認証のためのモジュール
mod_ldap LDAP操作用のモジュール

これらを有効にするため httpd.conf に以下の修正を加える。ところで、差分で例を示すのは、既存の環境にどのような差分を適用すれば良いかわかるようにすることを狙っている。

--- httpd-file.conf 2018-03-02 08:08:00.000000000 +0900
+++ httpd.conf  2018-03-02 08:08:12.000000000 +0900
@@ -10,7 +10,8 @@
 LoadModule authn_core_module modules/mod_authn_core.so
 LoadModule authz_user_module modules/mod_authz_user.so
 LoadModule auth_basic_module modules/mod_auth_basic.so
-LoadModule authn_file_module modules/mod_authn_file.so
+LoadModule authnz_ldap_module modules/mod_authnz_ldap.so
+LoadModule ldap_module modules/mod_ldap.so

 <IfModule unixd_module>
 User daemon
@@ -29,9 +30,12 @@
     AllowOverride None

     AuthType Basic
-    AuthBasicProvider file
-    AuthUserFile /usr/local/apache2/htdocs/.htpasswd
-    AuthName "password file authentication"
+    AuthBasicProvider ldap
+    AuthName "LDAP authentication"
+    AuthLDAPURL ldap://ホストのIPアドレス/ou=Users,dc=hoge,dc=local?cn
+
+    AuthLDAPBindDN cn=admin,dc=hoge,dc=local
+    AuthLDAPBindPassword changeme
     Require valid-user
 </Directory>

項目 設定値 説明
AuthBasicProvider ldap LDAP認証を利用する指定
AuthName "LDAP authentication" 任意の文字列
AuthLDAPURL ldap://ホストのIPアドレス/ou=Users,dc=hoge,dc=local?cn LDAPサーバのURL。URLでBaseDNや認証に利用するIDも指定している
AuthLDAPBindDN cn=admin,dc=hoge,dc=local LDAPサーバ管理者ユーザ(BindDN)
AuthLDAPBindPassword changeme LDAPサーバ管理者パスワード
Require valid-user 認証されたユーザのみ許可する指定、ここで細かくユーザの条件を指定することもできる

再度、http://localhost:8083/ を開いて今度はuserAでBasic認証されることを確認する。

最後に、ldapとfile認証の組み合わせを示す。

--- httpd-ldap.conf 2018-03-02 08:08:12.000000000 +0900
+++ httpd.conf  2018-03-02 08:17:54.000000000 +0900
@@ -12,6 +12,7 @@
 LoadModule auth_basic_module modules/mod_auth_basic.so
 LoadModule authnz_ldap_module modules/mod_authnz_ldap.so
 LoadModule ldap_module modules/mod_ldap.so
+LoadModule authn_file_module modules/mod_authn_file.so

 <IfModule unixd_module>
 User daemon
@@ -30,12 +31,14 @@
     AllowOverride None

     AuthType Basic
-    AuthBasicProvider ldap
-    AuthName "LDAP authentication"
+    AuthBasicProvider ldap file
+    AuthName "LDAP or password file authentication"
     AuthLDAPURL ldap://ホストのIPアドレス/ou=Users,dc=hoge,dc=local?cn
+    AuthUserFile /usr/local/apache2/htdocs/.htpasswd

     AuthLDAPBindDN cn=admin,dc=hoge,dc=local
     AuthLDAPBindPassword changeme
+    #AuthLDAPBindAuthoritative off
     Require valid-user
 </Directory> 

これで、LDAPに存在しないユーザは.htpasswdファイルで認証される。
これを試したのは以下のディレクティブを検証したかったからである。

項目 設定値 説明
AuthLDAPBindAuthoritative on(デフォルト) 今回の例で説明すると、onの場合、LDAPに存在しないユーザのみパスワードファイルの認証が試される。(だからuserFでログインできる)offの場合、例えばuserAでの認証でパスワードが違った場合でも、パスワードファイルでの認証が試される。userFの認証についてはどちらでも変わらない。

よく、このディレクティブをoffにする例を見るが、複数の認証機構で重複してユーザを管理したい場合に必要な設定であり、基本はデフォルト(on)のままでもいいのではないかと思う。また、このディレクティブは、AuthzLDAPAuthoritative, AuthLDAPAuthoritative など名前の変遷があったようで結構混乱する。

Subversion

まずはパスワードファイルでの認証を行うSubversionリポジトリを構築する。

以下は、カレンティディレクトリに検証用のsvn/testというリポジトリを作るスクリプト。
カレントディレクトリのsvn, testというディレクトリを最初に消しに行くので注意。

setup-svnrepo.sh
set -ex

rm -rf svn test
mkdir svn
echo websvn.txt short message > svn/websvn.txt

touch svn/svn.htpasswd
htpasswd -b svn/svn.htpasswd userF hogehoge
chmod 600 svn/svn.htpasswd
#chown 1000:1000 svn/svn.htpasswd

svnadmin create svn/test

svn co file://$PWD/svn/test test
( cd test
  echo hello svn > README.md
  svn add README.md
  svn commit -m 'test'
)
rm -rf test
$ sh ./setup-svnrepo.sh

上記で、パスワード認証用のファイル svn/svn.htpasswd ファイルも作成している。

次にdocker環境を構築する。前のhttpd:alpineを元に、subversionを追加したイメージを以下で作成する。これで、インストールするsubversionは執筆時点で1.9.7になるようだ。

Dockerfile
FROM httpd:alpine

RUN apk --no-cache add mod_dav_svn subversion
docker-compose.yml
version: '2'

services:
  app:
    build: .
    volumes:
      - ./httpd.conf:/usr/local/apache2/conf/httpd.conf
      - ./htdocs:/usr/local/apache2/htdocs
      - ./svn:/usr/local/apache2/svn
    ports:
      - 8084:80

そして、 httpd.conf は以下の通りとなる。httpd:alpine のイメージはapacheをビルドしているため、/usr/local/apache2にインストールされているが、apkでインストールしたmod_dav_svnは/usr/lib/apache2にインストールされるらしい。LoadModuleのところでdav_svn_module,authz_svn_moduleモジュールのパスが違うのはそのためである。

httpd.conf
ServerRoot "/usr/local/apache2"

Listen 80
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule autoindex_module modules/mod_autoindex.so
LoadModule dir_module modules/mod_dir.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_user_module modules/mod_authz_user.so
LoadModule auth_basic_module modules/mod_auth_basic.so
LoadModule authn_file_module modules/mod_authn_file.so
LoadModule dav_module modules/mod_dav.so
LoadModule dav_svn_module /usr/lib/apache2/mod_dav_svn.so
#LoadModule authz_svn_module /usr/lib/apache2/mod_authz_svn.so

<IfModule unixd_module>
User daemon
Group daemon
</IfModule>

ServerAdmin you@example.com
<Directory />
    AllowOverride none
    Require all denied
</Directory>

DocumentRoot "/usr/local/apache2/htdocs"
<Directory "/usr/local/apache2/htdocs">
    Options Indexes FollowSymLinks
    AllowOverride None
    Require all granted
</Directory>

<Location /svn>
  DAV svn
  SVNParentPath /usr/local/apache2/svn
  SVNListParentPath on

  AuthType Basic
  AuthBasicProvider file
  AuthUserFile /usr/local/apache2/svn/svn.htpasswd
  AuthName "Subversion Repository"
  #AuthzSVNAccessFile /usr/local/apache2/svn/svn.authz
  Require valid-user
</Location>

<IfModule dir_module>
    DirectoryIndex index.html
</IfModule>

<Files ".ht*">
    Require all denied
</Files>

ErrorLog /proc/self/fd/2
LogLevel warn

LogFormat "%h %l %u %t \"%r\" %>s %b" common
CustomLog /proc/self/fd/1 common

AuthzSVNAccessFile (authz_svn_moduleモジュール) はsvnのフォルダ毎に細かくアクセス権を設定するためのものだが、今回の例では使わないことにする。

この状態で、以下でイメージのビルドと起動を行う。

$ docker-compose up

そして、 http://localhost:8084/svn/test がuserFで認証できることを確認する。

次に、LDAP認証を試す。 やることはApacheの場合と同じである。httpd.conf は以下のように修正する。

--- httpd.conf.orig     2018-03-02 11:25:04.000000000 +0900
+++ httpd.conf  2018-03-02 11:26:56.000000000 +0900
@@ -14,6 +14,8 @@
 LoadModule dav_module modules/mod_dav.so
 LoadModule dav_svn_module /usr/lib/apache2/mod_dav_svn.so
 #LoadModule authz_svn_module /usr/lib/apache2/mod_authz_svn.so
+LoadModule authnz_ldap_module modules/mod_authnz_ldap.so
+LoadModule ldap_module modules/mod_ldap.so

 <IfModule unixd_module>
 User daemon
@@ -39,10 +41,14 @@
   SVNListParentPath on

   AuthType Basic
-  AuthBasicProvider file
-  AuthUserFile /usr/local/apache2/svn/svn.htpasswd
+  AuthBasicProvider ldap
   AuthName "Subversion Repository"
   #AuthzSVNAccessFile /usr/local/apache2/svn/svn.authz
+
+  AuthLDAPURL ldap://ホストのIPアドレス/ou=Users,dc=hoge,dc=local?cn
+  AuthLDAPBindDN cn=admin,dc=hoge,dc=local
+  AuthLDAPBindPassword changeme
+
   Require valid-user
 </Location>
$ docker-compose up

で、起動して、 http://localhost:8084/svn/test をuserAで認証できることを確認する。

また、userAでチェックアウトできることも確認する。

$ svn co --username=userA http://localhost:8084/svn/test/

Redmine

Redmine

docker-compose.yml
version: '2'

services:
  app:
    image: redmine
    ports:
      - 8085:3000
    environment:
      REDMINE_DB_MYSQL: db
      REDMINE_DB_PASSWORD: example
    depends_on:
      - db

  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: example
      MYSQL_DATABASE: redmine
    volumes:
      - ./db:/var/lib/mysql

以下で起動する。最初に db ディレクトリを作っておく必要があることに注意。

$ mkdir db
$ docker-compose up
項目
URL http://localhost:8085/
デフォルトの管理者ユーザ admin
デフォルトのパスワード admin

管理者でログインし、左上のメニュー「管理」->「LDAP認証」を選ぶ。そして、「新しい認証方式」をクリックし、以下の設定を入れる。

項目 設定値
名称 openldap (なんでもいい)
ホスト openLDAPを起動したホスト側のIPアドレス
ポート 389 (LDAPSのチェックはつけない)
アカウント cn=admin,dc=hoge,dc=local
パスワード changeme
ベースDN ou=Users,dc=hoge,dc=local
LDAPフィルタ 空欄
タイムアウト 空欄
あわせてユーザを作成 チェックする
ログインIDの属性 cn
名の属性 givenName (任意)
性の属性 sn (任意)
メールアドレスの属性 mail (任意)

名、性、メールアドレスは、省略するとログイン時に入力を求められる。結局必須属性なので、上記で設定しておいたほうがいい。ただ、名、性は漢字だとInternal errorになった。メールアドレスの形式(xxx@xxxx.xxx)も厳密にチェックされる。

Rocket.Chat

Rocket.Chat

Rocket.Chat の公式のdocker-compose.ymlだと、mongodbの起動待ちに間に合わず起動しないことが多かったのでwait-for-itで待ち合わせを行うようにした。
しかし、Rocket.ChatのDockerイメージはPORTを環境変数に設定しており、wait-for-it.shは内部でPORT変数を接続確認先のmongodbのポートで上書きする。結果起動はできるが接続できないことになってしまう。仕方ないので、起動時にPORT=3000を再設定している。
さらにそれでも待ち合わせが失敗することがあるようなので、これは単純に、 command: bash -c 'sleep 5 && node main.js' の方がいいかもしれない。

Dockerfile
From rocketchat/rocket.chat:latest

RUN curl -O https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
  chmod 755 wait-for-it.sh
docker-compose.yml
version: '2'

services:
  rocketchat:
    build: .
    volumes:
      - ./uploads:/app/uploads
    depends_on:
      - mongo
    ports:
      - 8086:3000
    environment:
      - ROOT_URL=http://localhost:8086
    command: bash ./wait-for-it.sh -t 0 -s mongo:27017 -- bash -c 'PORT=3000 exec node main.js'

  mongo:
    image: mongo:latest
    volumes:
     - ./data/db:/data/db
     - ./data/dump:/dump
    command: mongod --smallfiles --oplogSize 128 --replSet rs0

  mongo-init-replica:
    image: mongo:latest
    command: 'mongo mongo/rocketchat --eval "rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})"'
    depends_on:
      - mongo
docker-compose up

起動したら http://localhost:8086 に接続し、ユーザ登録を行う。最初に登録したユーザが管理者になる。(あるいは、https://rocket.chat/docs/administrator-guides/create-the-first-admin/ に管理者を登録する方法がある。)

管理者でログイン後、メニューの管理からLDAPを開く。(http://localhost:8086/admin/LDAP でも可)

項目 設定値
有効にする はい
Login Fallback はい
ホスト openLDAPを起動したホスト側のIPアドレス
ポート 389
Reconnect いいえ
暗号化 暗号化なし
CA証明書 暗号化ありの場合のみ設定可
認証できなければ拒否する 暗号化ありの場合のみ設定可
Base DN ou=Users,dc=hoge,dc=local
Internal Log Level Disabled

上記の状態で一旦設定を保存して、接続テストを行う。

Authentication

項目 設定値
Enable はい
User DN cn=admin,dc=hoge,dc=local
Password changeme

Sync/Import

Rocket.Chatではユーザの名前について以下の設定がある。

設定 意味 既定の対応するLDAP属性 この記事での設定例
Search Field ログイン時のID sAMAccountName cn
ユーザ名 @xxx の xxx部分 sAMAccountName cn
名前 ユーザ情報を参照したときに表示される名前。フルネームなど ログイン時に入力。「データを同期する」を選んだ場合はcn displayName

sAMAccountName はActiveDirectory向けの属性。
また、注意点としてメールアドレスの設定がないLDAPユーザでログインを行おうとすると、「ユーザーが見付からないか、パスワードが間違っています」というエラーでログインできない。

項目 設定値 補足
ユーザ名フィールド cn 空欄にすると各ユーザがログイン時に入力する(早い者勝ちで任意に選べる)ようになる。
一意性を識別するフィールド objectGUID,ibm-entryUUID,GUID,dominoUNID,nsuniqueId,uidNumber よくわからない。このままでも支障はなさそう
既定ドメイン 空欄
Merge Existing Users いいえ LDAPなしで既に運用していたなら「はい」にした方がいいかもしれない
データを同期する はい LDAPで一元管理するなら「はい」がいいだろう。各自が名前、メールアドレスをプロフィール設定で書き換えても次のログイン時にLDAPの設定でリセットされる
ユーザーデータのフィールドマップ {"displayName":"name", "mail":"email"} 名前の変更くらいは許可したい場合は{"mail":"email"}とする
ユーザーのアバターを同期する はい LDAPの jpegPhoto 属性でアバターを設定できるらしい #issue 2042
Background Sync いいえ

User Search

Search Field 以外はデフォルトのままとした。

項目 設定値
Filter (objectclass=*)
Scope sub
Search Field cn
Search Page Size 250
Search Size Limit 1000

Knowledge

Knowledge の場合、デフォルトのアクセス権限(ACL)ではログインできない。
ログインユーザがBaseDN配下を検索できなければならないからである。

そこで、以下のLDIFを作成する。

ldif/acl.ldif
dn: olcDatabase={1}hdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword,shadowLastChange
    by self write
    by dn="cn=admin,dc=hoge,dc=local" write
    by anonymous auth
    by * none
#olcAccess: {1}to * by * read
olcAccess: {1}to *
    by users read
olcAccess: {2}to *
    by self write
    by dn="cn=admin,dc=hogehoge,dc=local" write
    by * none

以下のようにLDAPの設定を修正して ACL を変更する。
ここで、changeme2 は、openldap/docker-compose.yml の LDAP_CONFIG_PASSWORD (ADMINではなくCONFIG)で指定したパスワードである。

$ ldapmodify -D cn=admin,cn=config -w changeme2 -f ldif/acl.ldif

(cn=config、と"cn"であることに注意。ここは間違えやすい。なんだ、cnでも階層を作れるではないか。ここにきて、LDAPのディレクトリ構造はなんでもありで、決めの問題であることを悟った)

コメントで示した以下の例だと誰でも参照できる指定、問題の切り分け等に使う。

olcAccess: {1}to * by * read

以下の例だとusersはログインに成功したユーザを意味し、それに参照権限を与える指定となっている。今回適用したのはこちら。

olcAccess: {1}to * by users read

これら{0}などの、数字は権限のチェック順序に関係し、{0}{2}は既存の設定である。ここでは、olcAccess: {1}の設定を挿入したいのでこのように書いてある。

なお、適用した結果は以下のファイルに書かれてある。以下は変更前の内容であり、既存設定はこれを参照した

slapd.d/cn=config/olcDatabase={1}hdb.ldif
〜略〜
olcAccess: {0}to attrs=userPassword,shadowLastChange by self write by dn="cn
 =admin,dc=hoge,dc=local" write by anonymous auth by * none
olcAccess: {1}to * by self write by dn="cn=admin,dc=hoge,dc=local" write by
 * none
〜略〜

以下で、起動する。

docker-compose.yml
docker-knowledge:
  image: koda/docker-knowledge
  volumes:
    - ./data:/root/.knowledge
  ports:
    - 8082:8080
$ docker-compose up
項目
URL http://localhost:8082/
デフォルトの管理者ユーザ admin
デフォルトのパスワード admin123

右上の人形アイコンからサインインを選び、管理者でログインする。
ログイン後、同じく右上のアイコンからシステム設定を選び、LDAP設定を選ぶ。

追加を押し、以下の設定を行う。

項目 設定値
設定名 openldap (なんでもいい)
Host openLDAPを起動したホスト側のIPアドレス
Port 389
Security Plain をチェック
検索用に接続するためのBind DN cn=admin,dc=hoge,dc=local
検索用に接続するためのパスワード changeme
ユーザ検索のBaseDN ou=Users,dc=hoge,dc=local
ユーザ検索のFilter (cn=:userid)
Id Attribute cn
Name Attribute givenName (任意)
Mail Address Attribute cn または mail (任意)
管理者 admin

Wekan

Wekan が最近LDAPに対応したのでその設定を示す。

Dockerのイメージは公式の mquandalle/wekanを利用している。

Wekan はデータをmongodbで保持しており、そのフォルダをカレントディレクトリのdataディレクトリとしている。

LDAPの設定は、docker-compose.yml 内のLDAP_で始まる環境変数で設定する。
全ての設定項目は wekan/wekan-ldapや公式のdocker-compose.ymlに記載があるが項目が大変多く混乱しやすい。最低限必要だったのは以下だった。最初は、LDAP_LOG_ENABLED=true としておいてログ(docker-compose logs -f)を参照した方が良い。

LDAP_HOST= はこれまで同様環境に合わせて書き換えること。
公開するポートを 8087 としているが、書き換える箇所が2箇所あるので注意。

LDAP_USERNAME_FIELDLDAP_USER_SEARCH_FIELDと同じであれば必要なさそう(LDAP_USER_SEARCH_FIELDは必須)。
LDAPユーザにメールアドレスが必須となっている。mailフィールドがないユーザはログインできないが、画面上何もエラーを表示しないためログ(Error: LDAP Authentication succeded, there is no email to create an account.というメッセージが出る)を見ないとそのことに気づかない。

docker-compose.yml
wekan:
  image: mquandalle/wekan
  restart: always
  links:
    - wekandb
  environment:
    - MONGO_URL=mongodb://wekandb/wekan
    - ROOT_URL=http://localhost:8087
    # - DEFAULT_AUTHENTICATION_METHOD=ldap
    - LDAP_ENABLE=true
    - LDAP_PORT=389
    - LDAP_HOST=openLDAPを起動したホスト側のIPアドレス
    - LDAP_AUTHENTIFICATION=true
    - LDAP_AUTHENTIFICATION_USERDN=cn=admin,dc=hoge,dc=local
    - LDAP_AUTHENTIFICATION_PASSWORD=changeme
    - LDAP_BASEDN=ou=Users,dc=hoge,dc=local
    - LDAP_USER_SEARCH_FIELD=cn
    #- LDAP_USERNAME_FIELD=cn
    - LDAP_FULLNAME_FIELD=displayName
    - LDAP_LOG_ENABLED=true
  ports:
    - 8087:8080

wekandb:
   image: mongo
   volumes:
     - ./data:/data/db
$ docker-compose up

ログインするときは、ログイン画面(以下)の Authentication method をLDAPに変える必要がある。設定項目の DEFAULT_AUTHENTICATION_METHOD=ldap は効果がなかったが試行錯誤が足りていない可能性がある。

OpenLDAPコマンド概説

検索

LDAPデータの内容を検索、参照するにはldapsearchコマンドを使う。

$ ldapsearch -D cn=admin,dc=hoge,dc=local -w changeme -b dc=hoge,dc=local

このldapsearchコマンドの結果は以下のようになる。ldapsearchではパスワードや漢字はbase64で、エンコードされた形式で表示される。

# extended LDIF
#
# LDAPv3
# base <dc=hoge,dc=local> with scope subtree
# filter: (objectclass=*)
# requesting: ALL

# hoge.local
dn: dc=hoge,dc=local
objectClass: top
objectClass: dcObject
objectClass: organization
o: HogeHoge Group
dc: hoge

# admin, hoge.local
dn: cn=admin,dc=hoge,dc=local
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: admin
description: LDAP administrator
userPassword:: e1NTSEF9QW5JM3pQSDVaT2dnZlR0eHZJOFlRNWJ6a2RaL3VCZ00=

# search result
search: 2
result: 0 Success

# numResponses: 3
# numEntries: 2

最後の numEntries: 2 で検索結果が2件であることを示している。まれに検索結果が失敗して、

# search result
search: 2
result: 32 No such object

# numResponses: 1

という表示を1件ヒットと勘違いしてしまう場合があるので注意が必要である。

ldapsearch コマンドの引数

引数 説明
-L, -LL, -LLL 出力書式、上の例では使っていないが、Lが多くなるほど単純な出力書式になる。慣れれば、-LLL が見やすい
-D ユーザ 接続ユーザ。BindDNと言われることもある。上の例では、初期設定で作成されている管理者ユーザ cn=admin,dc=hoge,dc=local で接続している。このユーザはディレクトリ全体の読み書き権限を持っている
-w パスワード -D で指定したユーザのパスワード。これは、docker-compose.yml で指定した環境変数 LDAP_ADMIN_PASSWORD で指定されている。このパスワードは後で変えるべきである
-H URL 接続先の指定。デフォルトでは ldap:/// (ローカルホストの389番ポート) を指定したものとなる。この記事では省略する
-b 検索開始位置 BaseDNとも言う。上の例では、 dc=hoge,dc=local 配下を検索することを示しており、その配下の階層を検索することを意味する
フィルタ 上の例では指定していないが、オプション指定のない最初の引数は、検索結果のデータをフィルタする条件に利用する。デフォルトでは、"(objectclass=*)" を指定したことになる。
属性 これも上の例では指定していないが、オプション指定のない第二引数は、結果を出力する属性を指定する。デフォルトでは、ALL を指定したことになる。
-s sub scope を指定する。後述。
-a never deref を指定する。後述。

フィルタと属性を指定した例を示す。

  • フィルタ指定

    $ ldapsearch -LLL -D cn=admin,dc=hoge,dc=local -w changeme -b dc=hoge,dc=local "(cn=admin)"
    
    dn: cn=admin,dc=hoge,dc=local
    objectClass: simpleSecurityObject
    objectClass: organizationalRole
    cn: admin
    description: LDAP administrator
    userPassword:: e1NTSEF9QW5JM3pQSDVaT2dnZlR0eHZJOFlRNWJ6a2RaL3VCZ00=
    
  • 属性指定

    $ ldapsearch -LLL -D cn=admin,dc=hoge,dc=local -w changeme -b dc=hoge,dc=local "(cn=admin)" description
    
    dn: cn=admin,dc=hoge,dc=local
    description: LDAP administrator
    

scope

-sは、検索範囲を示すオプション。以下のいずれかを指定する(デフォルトは-s sub)。値はopenldapのログに現れる数値 (scope=0などと表示される)。認証がうまく行かない場合にどのような検索が行われているかを調べるのに以下の表が必要になる。

オプション指定 意味
base BaseDNのみ 0
one BaseDNとその直下のみ 1
sub BaseDN配下すべて 2
children BaseDNを除く、その配下すべて 3

deref

-aは、エイリアスの解釈方法を指定するオプション。以下のいずれかを指定する(デフォルトは-a never)。-s同様に、openldapのログにderef=0などと表示される。

オプション指定 意味
never 常に、エイリアスを解決しない 0
search 検索対象のみ、エイリアス解決する 1
find BaseDNのみエイリアス解決する 2
always 常に、エイリアス解決する 3

上記の意味はマニュアルの字面から解釈したが試してはいない。

追加

$ ldapadd -D cn=admin,dc=hoge,dc=local -w changeme -f ldif/cn-userA.ldif

-f以外は、ldapsearchと同じ。もちろん、標準入力(キーボード入力)を使ってもよいがあまりやらないだろう。

パスワードはハッシュ値を設定する必要があるので実運用では最後に示すようなスクリプトを作るのが良いだろう。

修正

例えば、userA の sn を修正したいとする。以下のようにする

$ ldapmodify -D cn=admin,dc=hoge,dc=local -w changeme
〜以下、キーボードからの入力〜
dn: cn=userA,ou=Users,dc=hoge,dc=local
replace: sn
sn: fugafuga
^D

修正は追加とは書式が異なるのでLDIFファイルを作らず、標準入力から入力する方法を示している。
しかし、どうも指定が冗長である。replace: snと書いたら sn 以外のことを書くことはできない。だったら、replace: snという行はいらないと思うのだが。

修正したい場合は、LDIFファイルの修正の後、削除と追加をやった方が簡単(階層上、配下のエントリを持つ時にはldapmodifyで頑張るしかない)

削除

$ echo "cn=userA,ou=Users,dc=hoge,dc=local" | ldapdelete -D cn=admin,dc=hoge,dc=local -w changeme

ldapdelete 実行時に "dn: " の文字が必要ないことに注意。全く使いにくい。

追加した時のLDIFファイルを使って

$ head -1 ldif/cn-userA.ldif | cut -c4- | ldapdelete -D cn=admin,dc=hoge,dc=local -w changeme

などとすると確実。

ユーザ追加、修正、削除スクリプトの例

ユーザを追加、削除するシェルスクリプトの例

#!/bin/bash

admin='cn=admin,dc=hoge,dc=local'
adminpass='changeme'
basedn='ou=Users,dc=hoge,dc=local'

usage() {
  echo "usage:"
  echo "  $0 [--] userid [userid2...]" >&2
  echo "  $0 search [userid...]" >&2
  echo "  $0 add userid displayName sn mail userPassword" >&2
  echo "  $0 del userid" >&2
  echo "  $0 mod userid xxx=xxx [xxx=xxx...]" >&2
  exit 2
}

search() {
  if [ $# -eq 0 ]; then
    ldapsearch -LLL -D $admin -w $adminpass -b $basedn "(cn=*)"
  else
    for userid; do
      ldapsearch -LLL -D $admin -w $adminpass -b $basedn "(cn=$userid)"
    done
  fi
}

add() {
  if [ $# -lt 5 ]; then
    usage
  fi

  userid="$1"; shift
  displayName="$1"; shift
  sn="$1"; shift
  mail="$1"; shift
  userPassword="$1"; shift

  pass=$(slappasswd -h '{MD5}' -s "$userPassword")
  cat <<END | ldapadd -D $admin -w $adminpass
dn: cn=$userid,$basedn
objectClass: inetOrgPerson
displayName: $displayName
sn: $sn
mail: $mail
userPassword: $pass
END
  search $userid
}

del() {
  if [ $# -lt 1 ]; then
    usage
  fi
  userid="$1"; shift

  echo "cn=$userid,$basedn" | ldapdelete -D $admin -w $adminpass
  search $userid
}

mod2() {
  replacement="$1"; shift
  case $replacement in
  *=*)
    IFS_sv="$IFS" IFS=$'='
    set -- $(echo "$replacement")
    IFS="$IFS_sv"
    id="$1" val="$2"
    ;;
  *) usage;;
  esac

  cat <<END | ldapmodify -D $admin -w $adminpass
dn: cn=$userid,$basedn
replace: $id
$id: $val
END
}

mod() {
  if [ $# -lt 1 ]; then
    usage
  fi
  userid="$1"; shift
  for replacement in "$@"
  do
    mod2 "$replacement"
  done

  search $userid
}

if [ $# -eq 0 ]; then
  usage
fi

opt="$1"; shift
case "$opt" in
add) add ${1+"$@"} ;;
del) del ${1+"$@"} ;;
mod) mod ${1+"$@"} ;;
search|--) search "$@" ;;
*) search "$opt" ${1+"$@"} ;;
esac
75
84
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
75
84