2017/06/02


認証を持たないウェブアプリケーションをいざ認証に対応させようと思うと案外面倒でモチベーションを無くしてしまうなんて事もよく起きうる話です。特に社内向けのアプリケーションを作っていたら本番で使う事になってしまって、なんて話は良くある話です。開発で本番 DB を見るのはちょっと...。でも既存のコードをゴリゴリと触りたくない。そんな場合にログイン認証部分だけマイクロサービス化できると気持ちも幾分和らぎます。今日はそんなちょっと便利なサーバ「loginsrv」を紹介したいと思います。

GitHub - tarent/loginsrv: JWT login microservice with plugable backends such as OAuth2, Github, htpasswd, osiam

loginsrv is a standalone minimalistic login server providing a JWT login for multiple login backends.

https://github.com/tarent/loginsrv

loginsrv は JWT トークンを使って安全にユーザ識別をやりとり出来る単体のマイクロサービスです。既存のコードを JWT トークンに対応させるだけで認証機能を代行してくれます。また一度この対応を行っておけば loginsrv を使わない実装になったとしても簡単に取り換えられるという訳です。導入方法を紹介して行きます。

loginsrv の起動方法は以下の通り。

Usage of loginsrv:
  -backend value
        Deprecated, please use the explicit flags
  -cookie-domain string
        The optional domain parameter for the cookie
  -cookie-expiry duration
        The expiry duration for the cookie, e.g. 2h or 3h30m. Default is browser session
  -cookie-http-only
        Set the cookie with the http only flag (default true)
  -cookie-name string
        The name of the jwt cookie (default "jwt_token")
  -github value
        Oauth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..]
  -grace-period duration
        Graceful shutdown grace period (default 5s)
  -host string
        The host to listen on (default "localhost")
  -htpasswd value
        Htpasswd login backend opts: file=/path/to/pwdfile
  -jwt-expiry duration
        The expiry duration for the jwt token, e.g. 2h or 3h30m (default 24h0m0s)
  -jwt-refreshes int
        The maximum amount of jwt refreshes. 0 by Default
  -jwt-secret string
        The secret to sign the jwt token (default "random key")
  -log-level string
        The log level (default "info")
  -login-path string
        The path of the login resource (default "/login")
  -logout-url string
        The url or path to redirect after logout
  -osiam value
        Osiam login backend opts: endpoint=..,client_id=..,client_secret=..
  -port string
        The port to listen on (default "6789")
  -simple value
        Simple login backend opts: user1=password,user2=password,..
  -success-url string
        The url to redirect after login (default "/")
  -template string
        An alternative template for the login form
  -text-logging
        Log in text format instead of json

認証方法が幾らか用意されています。

種別説明
htpasswdMD5やSHA1、Bcryptでパスワードがエンコードされたファイル。
OSIAMRESTで使用できる認証管理サーバ。
Simple引数でユーザとパスワードを指定。
OAuth2ご存じ OAuth2。現在は組み込みプロバイダとしてGitHubのみサポート。

社内で .htaccess で運用されていた物を使うなんて事も可能です。

JWT トークンの対応には秘密キーが必要です。openssl コマンド等で生成しますがここでは割愛。この秘密キーを loginsrv と共存させる事でユーザ識別のやり取りを可能にします。loginsrv は -jwt-secret という引数で受け取る事も出来ますが、LOGINSRV_JWT_SECRET という環境変数で受け渡す事もできます。

package main

import (
    "fmt"
    "net/http"
    "os"

    "github.com/dgrijalva/jwt-go"
)

var privateHtml = `
こんにちわ %s さん<br />
<a href="/login?logout=true">ログアウト</a>
`

var publicHtml = `
<a href="/login">ログイン</a>
`

func main() {
    secret := os.Getenv("LOGINSRV_JWT_SECRET")
    http.HandleFunc("/"func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("content-type""text/html; charset=utf8")
        if c, err := r.Cookie("jwt_token"); err == nil {
            token, err := jwt.Parse(c.Value, func(*jwt.Token) (interface{}, error) {
                return []byte(secret), nil
            })
            if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }
            if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
                fmt.Fprintf(w, privateHtml, claims["sub"])
                return
            }
        }
        fmt.Fprintln(w, publicHtml)
    })
    http.ListenAndServe(":8888"nil)
}

jwt_token というクッキーを秘密鍵で照合し資格情報 claim を表示しています。本来ならば loginsrv に依存させない様に LOGINSRV_JWT_SECRET ではなく引数などから秘密鍵を貰って下さい。

あとはこのアプリケーションを loginsrv と一緒に起動させます。せっかくなので goreman (foremanクローン) を使います。Procfile は以下の通り。

web1: ./app
web2: loginsrv -htpasswd file=/path/to/htaccess
gorem: gorem

ここで起動している gorem はカスタマイザブルなリバースプロキシサーバです。設定ファイル config.json は以下の通り。

{
  "app": {
    "address": "127.0.0.1:5000",
    "entries": [
      {
        "path": "/login",
        "backend": "http://localhost:6789",
        "use_path": true
      },
      {
        "path": "/",
        "backend": "http://localhost:8888",
        "use_path": true
      }
    ]
  }
}

本番では gorem の代わりに nginx や apache を使うと良いでしょう。最後に JWT の秘密鍵を .env で設定します。

LOGINSRV_JWT_SECRET=deadbeef

ここまで出来たら goreman start を実行します。ブラウザで http://localhost:5000/ を開くとログインしていない時のコンテンツが表示されます。

ページ

リンクからログイン画面に移動すると以下の画面が表示されます。

ページ

GitHub プロバイダを使う場合は GitHub 認証用のボタンが表示されます。

GitHub認証

ログインが成功すると指定のURL(デフォルトは /)に戻ってきます。

ページ

開発では .htaccess を使い、本番では OAuth2 を使うといった運用が簡単に行える様になります。今回の例ではアプリケーションをそのまま使いましたが、実際は docker から起動できる様にしておくと以降の開発が便利になるかと思います。

みんなのGo言語[現場で使える実践テクニック] みんなのGo言語[現場で使える実践テクニック]
松木雅幸, mattn, 藤原俊一郎, 中島大一, 牧大輔, 鈴木健太
技術評論社 / (2016-09-09)
 
発送可能時間:


2017/05/10


おなじみC/C++から使えるJSONライブラリを紹介するコーナー。まずは過去のまとめ。

結構前からあった様だけど気付いて無かった。

GitHub - DaveGamble/cJSON: Ultralightweight JSON parser in ANSI C

README.md cJSON Ultralightweight JSON parser in ANSI C. Table of contents License Usage Welcome to c...

https://github.com/DaveGamble/cJSON

特徴は以下の通り。

  • 分かりやすい API
  • MIT ライセンス
  • スレッドセーフ
  • ANSI C (もしくは C89, C90) で書かれている

Not C++ で使える JSON パーサとしては分かりやすい API で良いと思います。参照カウンタを持っているのでメモリの解放もルートオブジェクトの破棄だけで行えます。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"

int
main(int argc, char* argv[]) {
  cJSON *root = cJSON_CreateObject(), *arr, *item; 

  cJSON_AddNumberToObject(root, "count"1);
  cJSON_AddItemToObject(root, "items", arr = cJSON_CreateArray());
  cJSON_AddItemToArray(arr, cJSON_CreateString("こんにちわ世界"));
  cJSON_AddItemToArray(arr, cJSON_CreateFalse());
  cJSON_AddItemToArray(arr, cJSON_CreateNull());
  puts(cJSON_PrintUnformatted(root));
  cJSON_free(root);
  
  root = cJSON_Parse("{\"count\":1, \"items\":[\"こんにちわ世界\",false,null]}");
  arr = cJSON_GetObjectItem(root, "items");
  cJSON_ReplaceItemInArray(arr, 0, cJSON_CreateString("こんにちわ日本"));
  cJSON_ArrayForEach(item, arr) {
    puts(cJSON_Print(item));
  }
  cJSON_free(root);
  return 0;
}

欲を言うとシリアライズ時にメモリを確保しない API が欲しいです。


2017/04/06


今日こんなツイートをした。

qt_luigi さんからどうしてかを聞かれたので説明したいと思います。

golang では宣言した位置で初めて自動変数としてメモリが確保され、ゼロクリアされます。

for i := 0; i < b.N; i++ {
    var foo Foo
    bar, err := doSomething()
    if err != nil {
        continue
    }
    foo.v = bar
    fmt.Fprintln(ioutil.Discard, foo)
}

なので例えばこの様なコードで doSomething() が err を返した場合、foo が無駄に初期化されてしまうのです。

本当にそうなのか、以下のベンチマークを見て貰えると分かります。

package var_test

import (
    "errors"
    "fmt"
    "io/ioutil"
    "testing"
)

type Foo struct {
    v *Bar
    b [1000]int64
}

type Bar struct {
}

func doSomething() (*Bar, error) {
    return nil, errors.New("bad some")
}

func BenchmarkVar1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var foo Foo
        bar, err := doSomething()
        if err != nil {
            continue
        }
        foo.v = bar
        fmt.Fprintln(ioutil.Discard, foo)
    }
}

func BenchmarkVar2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        bar, err := doSomething()
        if err != nil {
            continue
        }
        var foo Foo
        foo.v = bar
        fmt.Fprintln(ioutil.Discard, foo)
    }
}

通るはずのない所に Println を書いたのはコンパイラが最適化して消し去ってしまわない様にです。(本当に消し去るかは未確認)

goos: windows
goarch: amd64
pkg: github.com/mattn/go-sandbox/var
BenchmarkVar1-4     10000000           226 ns/op           0 B/op          0 allocs/op
BenchmarkVar2-4     2000000000           1.26 ns/op        0 B/op          0 allocs/op
PASS
ok      github.com/mattn/go-sandbox/var 5.222s

ちょっと大げさに int64 変数が1000個保持されるような struct で確認しているので180倍近い差が出ていますが、少し大きめの構造体でも幾らかは差が出てしまいます。early return は golang の良い文化ではありますが、さらに変数の宣言位置も気を付けておくとよりパフォーマンスの良いアプリケーションになっていくでしょう。

みんなのGo言語【現場で使える実践テクニック】 みんなのGo言語【現場で使える実践テクニック】
松木雅幸, mattn, 藤原俊一郎, 中島大一, 牧 大輔, 鈴木健太
技術評論社 / ¥ 2,138 (2016-09-09)
 
発送可能時間:在庫あり。