buzzfeed/sso の紹介とminikubeでのデプロイ方法

# はじめに

Webアプリケーションを作る際にユーザ認証を実装したいことがあります。
実装方法は様々ありますが今回は、[buzzfeed/sso](https://github.com/buzzfeed/sso) というプロダクトを紹介し、minikubeで作ったクラスタ上で動作させる方法を紹介します。

# KubeWeekly読書メモの宣伝
私はここ半年ほどKubeWeeklyの読書メモをQiitaに公開してきました。【見てね！　https://qiita.com/inajob 】
そのなかで、[buzzfeed/sso](https://github.com/buzzfeed/sso)の存在を知り、さらに　[Single Sign-On for Internal Apps in Kubernetes using Google Oauth / SSO](https://medium.com/@while1eq1/single-sign-on-for-internal-apps-in-kubernetes-using-google-oauth-sso-2386a34bc433) のようなよくできたチュートリアルも知ることができました。

この記事では上記のチュートリアルを元にminikubeで作ったクラスタ上で[buzzfeed/sso](https://github.com/buzzfeed/sso)を動作させる方法を紹介します。

# buzzfeed/sso とは

## buzzfeed/sso
<table>
<tr>
  <td colspan="2"><a href="https://github.com/buzzfeed/sso">buzzfeed/sso</a></td>
</tr>
<tr>
  <td><img src="https://user-images.githubusercontent.com/10510566/44476420-a64e5980-a605-11e8-8ad9-2820109deb75.png"></td>
  <td>sso, aka S.S.Octopus, aka octoboi, is a single sign-on solution for securing internal services - buzzfeed/sso</td>
</tr>
</table>


[buzzfeed/sso](https://github.com/buzzfeed/sso)はGoogleをOAuth2プロバイダとして使い、ログインを実現するための認証プロキシです。
認証プロキシは文字通り、アプリケーションの前段にプロキシを用意し、認証されていないユーザがきた場合は、認証画面に遷移されます。
認証されたユーザはアプリケーションにアクセスすることができます。
一度認証されたユーザはCookieにその情報が記録されるため、Cookieが有効な間は再認証の必要はありません。

もちろん認証プロキシをつかわずアプリケーションそのものに認証の仕組みを用意することもできますが、比較的バグが許されないロジックであり、アプリケーション各々が認証機能を実装すると、労力がかかるばかりか、バグを作り込んでしまい、セキュリティリスクを追ってしまう可能性もあります。
認証プロキシを使うことで、アプリケーションは本来の機能のみを実装し、認証の具体的なロジックを認証プロキシに任せることができます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/c85caef9-2907-f78b-445f-7c28d25e9b29.png)

よく似たものとして[bitly/oauth2_proxy](https://github.com/bitly/oauth2_proxy)がありましたが、構造上スケールが難しい、ということでbuzzfeedが作り始めたのが[buzzfeed/sso](https://github.com/buzzfeed/sso)です。

## Minikubeのセットアップ

今回はデモとしてMinikube上でssoを動かしてみます。
ingressを利用するのでaddonのingressを有効にします。

```
$ minikube start --kubernetes-version=v1.11.5
.. 結構待つ
$ minikube addons enable ingress
$ minikube ip  # IPアドレスの確認
192.168.99.100
```

minikubeのIPアドレスはこの場合192.168.99.100です。
今回はingressのドメインとして [xip.io: wildcard DNS for everyone](http://xip.io/)を利用します。
これは `"好きなサブドメイン".IPアドレス.xip.io`のドメインを提供し、その結果としてURLに埋め込んだIPアドレスを返すDNSサービスです。
つまり今回の場合は `hogehoge.192.168.99.100.xip.io`などという名前でクラスタ内のアプリケーションにアクセスできるようにするということです。

## Google Providerのセットアップ
次にGCPで認証プロバイダのセットアップを行います。
GCPでの認証プロバイッダの利用は無料で行うことができます。

https://console.developers.google.com/cloud-resource-manager まずはこの画面から プロジェクトを作成します。
プロジェクトの作成には数分かかります。

次にOAuthのクレデンシャルを作成します。

https://console.cloud.google.com/apis/credentials/consent ここから同意画面の設定を行います。
"同意を求めるアプリの名前"というところがユーザに同意を求めるときに出てくるアプリケーション名です。
また”承認済みドメイン”に"xio.io"を追加しておきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/cdf2d82d-136c-7fd9-cc6a-936cce270a66.png)


次にクレデンシャルを発行します。https://console.cloud.google.com/apis/credentials の画面から行います。
”認証情報を作成”をクリックし、”OAuth クライアント ID”を選択します。
ウィザードが始まるので、下記項目を記入します。

- アプリケーションの種類: Webアプリケーション
- 名前： (なんでも良いですが) Dev
- 承認済みの JavaScript 生成元: そのまま空白で
- 承認済みのリダイレクト URI: http://sso-auth.192.168.99.100.xip.io/oauth2/callback （192...のところはminikubeのIPアドレスによって変更します。）

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/d93a4d66-afbb-72ca-c230-d5037262574e.png)

全てを入力して、”作成”をクリックすることで"クライアントID"と"クライアントシークレット"が作成できます。
あとで使うので、どこかに保存しておきます。

## kubernetesのmanifestの用意
ここからminikube上のKubernetesクラスタにssoのデモをデプロイする手法を説明していきます。

### 認証後にアクセスさせるアプリケーション

認証後にアクセスさせるアプリケーションとしてhttpbinをデプロイしておきます。
これはHTTPリクエストを解析するツールで、リクエストヘッダに何がついていたかなどを見ることができます。

```httpbin.yaml
apiVersion: v1
kind: Service
metadata:
  name: httpbin
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    run: httpbin
---
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  labels:
    run: httpbin
  name: httpbin
spec:
  replicas: 1
  selector:
    matchLabels:
      run: httpbin
  template:
    metadata:
      labels:
        run: httpbin
    spec:
      containers:
      - image: kennethreitz/httpbin
        name: httpbin
        ports:
        - containerPort: 80
```

### sso Namespaceの作成

ssoがらみのコンポーネントは名前空間を分ける事にします。

```
$ kubectl create namespace sso
```

### secretの作成

cookieの暗号化に使う鍵などを用意します。
内容は適当な乱数です。

```
kubectl create secret generic -n sso proxy-client-id --from-literal=proxy-client-id=$(openssl rand -base64 32 | head -c 32 | base64)
kubectl create secret generic -n sso proxy-client-secret --from-literal=proxy-client-secret=$(openssl rand -base64 32 | head -c 32 | base64)
kubectl create secret generic -n sso auth-code-secret --from-literal=auth-code-secret=$(openssl rand -base64 32 | head -c 32 | base64)
kubectl create secret generic -n sso proxy-auth-code-secret --from-literal=proxy-auth-code-secret=$(openssl rand -base64 32 | head -c 32 | base64)
kubectl create secret generic -n sso auth-cookie-secret --from-literal=auth-cookie-secret=$(openssl rand -base64 32 | head -c 32 | base64)
kubectl create secret generic -n sso proxy-cookie-secret --from-literal=proxy-cookie-secret=$(openssl rand -base64 32 | head -c 32 | base64)
```

また、Google Providerから発行された認証情報もSecretとしてデプロイしておきます

```
kubectl create secret generic -n sso google-client-id --from-literal=client-id=XXXXXXX
kubectl create secret generic -n sso google-client-secret --from-literal=client-secret=XXXXXXXXX
```

### sso-auth
実際の認証を移譲するサービスです。cookieを確認し、未ログインの場合は Google OAuth2プロバイダにリダイレクトし、認証結果を受け取ります。

``` sso-auth.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: sso-auth
  labels:
    k8s-app: sso-auth
  namespace: sso
spec:
  replicas: 1
  template:
    metadata:
      labels:
        k8s-app: sso-auth
    spec:
      containers:
      - image: buzzfeed/sso-dev:latest
        name: sso-auth
        command: ["/bin/sso-auth"]
        ports:
        - containerPort: 4180
        env:
          - name: SSO_EMAIL_DOMAIN # *に設定できるようだ
            value: '*'
          - name: HOST # sso-authを配信しているDNS名
            value: sso-auth.192.168.99.100.xip.io
          - name: REDIRECT_URL # OAuth2プロバイダに伝えるリダイレクト先
            value: http://sso-auth.192.168.99.100.xip.io
          - name: PROXY_ROOT_DOMAIN # リダイレクト先として許容するドメイン
            value: 192.168.99.100.xip.io
          - name: CLIENT_ID
            valueFrom:
              secretKeyRef:
                name: google-client-id
                key: client-id
          - name: CLIENT_SECRET
            valueFrom:
              secretKeyRef:
                name: google-client-secret
                key: client-secret
          - name: PROXY_CLIENT_ID
            valueFrom:
              secretKeyRef:
                name: proxy-client-id
                key: proxy-client-id
          - name: PROXY_CLIENT_SECRET
            valueFrom:
              secretKeyRef:
                name: proxy-client-secret
                key: proxy-client-secret
          - name: AUTH_CODE_SECRET
            valueFrom:
              secretKeyRef:
                name: auth-code-secret
                key: auth-code-secret
          - name: COOKIE_SECRET
            valueFrom:
              secretKeyRef:
                name: auth-cookie-secret
                key: auth-cookie-secret
          # STATSD_HOST and STATSD_PORT must be defined or the app wont launch, they dont need to be a real host / port
          - name: STATSD_HOST
            value: localhost
          - name: STATSD_PORT
            value: "11111"
          - name: COOKIE_SECURE
            value: "false"
        readinessProbe:
          httpGet:
            path: /ping
            port: 4180
            scheme: HTTP
        livenessProbe:
          httpGet:
            path: /ping
            port: 4180
            scheme: HTTP
          initialDelaySeconds: 10
          timeoutSeconds: 1
        resources:
          limits:
            memory: "256Mi"
            cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
  name: sso-auth
  namespace: sso
  labels:
    k8s-app: sso-auth
spec:
  ports:
  - port: 80
    targetPort: 4180
    name: http
  selector:
    k8s-app: sso-auth
```

### upstream-config

sso-proxyの設定ファイルであるupstream-configを記述します。
これはsso-proxyに来たホスト名を見て、どのサービスにアクセスするかを振り分けるための設定です。

```config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: upstream-configs
  namespace: sso
data:
  upstream_configs.yml: |-
    - service: app1
      default:
        from: hello.192.168.99.100.xip.io # この名前が来たら
        to: http://httpbin.default.svc.cluster.local # この名前にプロキシ！
```

### sso-proxy
サービスの前段となるプロキシのサービスです。

```sso-proxy.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: sso-proxy
  labels:
    k8s-app: sso-proxy
  namespace: sso
spec:
  replicas: 1
  template:
    metadata:
      labels:
        k8s-app: sso-proxy
    spec:
      containers:
      - image: buzzfeed/sso-dev:latest
        name: sso-proxy
        command: ["/bin/sso-proxy"]
        ports:
        - containerPort: 4180
        env:
          - name: EMAIL_DOMAIN # *が指定できる
            value: '*'
          - name: UPSTREAM_CONFIGS # upstreamの設定
            value: /sso/upstream_configs.yml
          - name: PROVIDER_URL # sso-authに外から繋ぐための名前
            value: http://sso-auth.192.168.99.100.xip.io
          - name: PROVIDER_URL_INTERNAL # sso-authに内側からアクセスするための名前
            value: http://sso-auth.sso.svc.cluster.local
          - name: CLIENT_ID
            valueFrom:
              secretKeyRef:
                name: proxy-client-id
                key: proxy-client-id
          - name: CLIENT_SECRET
            valueFrom:
              secretKeyRef:
                name: proxy-client-secret
                key: proxy-client-secret
          - name: AUTH_CODE_SECRET
            valueFrom:
              secretKeyRef:
                name: proxy-auth-code-secret
                key: proxy-auth-code-secret
          - name: COOKIE_SECRET
            valueFrom:
              secretKeyRef:
                name: proxy-cookie-secret
                key: proxy-cookie-secret
          # STATSD_HOST and STATSD_PORT must be defined or the app wont launch, they dont need to be a real host / port, but they do need to be defined.
          - name: STATSD_HOST
            value: localhost
          - name: STATSD_PORT
            value: "11111"
          - name: COOKIE_SECURE
            value: "false"
        readinessProbe:
          httpGet:
            path: /ping
            port: 4180
            scheme: HTTP
        livenessProbe:
          httpGet:
            path: /ping
            port: 4180
            scheme: HTTP
          initialDelaySeconds: 10
          timeoutSeconds: 1
        resources:
          limits:
            memory: "256Mi"
            cpu: "200m"
        volumeMounts:
        - name: upstream-configs
          mountPath: /sso
      volumes:
        - name: upstream-configs
          configMap:
            name: upstream-configs
---
apiVersion: v1
kind: Service
metadata:
  name: sso-proxy
  namespace: sso
  labels:
    k8s-app: sso-proxy
spec:
  ports:
  - port: 80
    targetPort: 4180
    name: http
  selector:
    k8s-app: sso-proxy
```


### Ingress
sso-auth, sso-proxyをクラスタの外からアクセスできるようにするためのIngressを設定します。本当はSSLにした方が良いのですが、今回は簡単のためHTTPで設定しています。

```ingress.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: sso
  namespace: sso
spec:
  rules:
  - host: sso-auth.192.168.99.100.xip.io #  sso-auth のためのルーティング
    http:
      paths:
      - path: /
        backend:
          serviceName: sso-auth
          servicePort: 80
  - host: hello.192.168.99.100.xip.io # サービスのためのルーティング
    http:
      paths:
      - path: /
        backend:
          serviceName: sso-proxy # まずはsso-proxyに飛ばす (そのあとsso-proxyが対象サービスにプロキシする。)
          servicePort: 80
```

## アプリケーションの動作確認

まずはブラウザから`http://hello.192.168.99.100.xip.io/` にアクセスします
次のようにログインを促す画面が表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/2cba2610-39be-a98b-e1da-ed007a220c00.png)

次にログインするアカウントを選択する画面が表示されます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/ab307002-4142-cf5c-d4a1-43ec97224f9e.png)

無事ログインしてhttpbinのサービスが表示されました。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/490ff6f4-7b38-274a-743c-6ca98be614f9.png)

httpbinにはリクエストヘッダを見る機能があるので http://hello.192.168.99.100.xip.io/headers へアクセスしてみましょう

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100375/48d01190-64a0-586a-37fc-89a1d03edbe8.png)

ヘッダを介してログインしたe-mailアドレスやユーザ名が確認できます。

## まとめ

[buzzfeed/sso](https://github.com/buzzfeed/sso)を利用することでアプリケーションに手を加えることなく認証機能を追加することができました。
今回の例では本当にGoogleが認証した人は誰でも通してしまう設定ですが、GCPのOrganizationと紐づけてログインユーザを管理することもできるようです。

マイクロサービスの流行により、いままでWebアプリケーションフレームワークのミドルウェアが行なっていた処理が独立したサービスとして提供されるようになって来たように感じます。
今回紹介した[buzzfeed/sso](https://github.com/buzzfeed/sso)もその典型的な例で、"認証"という機能をマイクロサービス化したものと言えます。

このようなアプリケーションにより今まで個別に実装していたビジネスロジックとは無関係の処理を任すことができるようになると、アプリケーション開発者は本来のビジネスロジックに注力することができるようになり、より効率的な開発ができるようになると感じました。

このエントリは、弊社 Z Lab のメンバーによる Z Lab Advent Calendar 2018 の7日目として業務時間中に書きました。8日目は @shmurata の担当です。




## 参考
- buzzfeed/ssoのQuickStart
  - [sso/quickstart.md at master · buzzfeed/sso · GitHub](https://github.com/buzzfeed/sso/blob/master/docs/quickstart.md)
- Google Providerの設定方法
  - [sso/google_provider_setup.md at master · buzzfeed/sso · GitHub](https://github.com/buzzfeed/sso/blob/master/docs/google_provider_setup.md)
- よくできたチュートリアル
  - [Single Sign-On for Internal Apps in Kubernetes using Google Oauth / SSO](https://medium.com/@while1eq1/single-sign-on-for-internal-apps-in-kubernetes-using-google-oauth-sso-2386a34bc433)