2016/12/31


OAuth2 でレスポンスタイプがコードもしくはトークンの場合、ブラウザで認証を行ってコードやトークンを自前サーバで受け取る事になる。モバイルアプリだと組み込みブラウザが前提になっておりリダイレクトの最終 URL からアクセスコードやトークンを得る。ただコマンドラインアプリの場合、認証の為に起動したブラウザの最終 URL を得る方法はない。また1コマンドラインアプリケーションの為にドメイン付きのコールバックサーバを用意するのも面倒だし、作ったサーバをユーザに信用して貰う必要がある。あとそもそも外部のサーバで受け取ったトークンをどうやってコマンドラインアプリに渡すかという問題がある。

そこで使うのがローカルサーバを立てる方法。認証後のコールバック先をコマンドラインアプリから起動したローカルサーバにし、そこにリダイレクトさせてアクセストークンを貰い保存する。

今日はこれが伝わり易い用に Microsoft の OneNote API で認証するサンプルを書いてみる。

まずは Microsoft Application Registration Portal でアプリケーションを登録する。

マイ アプリケーション

Microsoft developer Windows Windows Dev Center Windows apps Desktop Internet of Things Games Hologra...

https://apps.dev.microsoft.com/#/appList

アプリケーションシークレットでパスワードを発行しそれを ClientSecret とすること。ちなみに ClientID はアプリケーションIDと一緒。またプラットフォームとしては Web アプリケーションを選びコールバックサーバ(Microsoft の API は省略不可)の URL として http://localhost:8989 を指定しておくこと。

本来ならばコールバック先がアプリケーション側から指定できるため、Golang であれば以下の oauth2.Config にて RedirectURL を自由に決められる。

oauthConfig := &oauth2.Config{
    Scopes: []string{
        "wl.signin",
        "wl.basic",
        "Office.onenote",
        "Office.onenote_update",
        "Office.onenote_update_by_app",
    },
    Endpoint: oauth2.Endpoint{
        AuthURL:  "https://login.live.com/oauth20_authorize.srf",
        TokenURL: "https://login.live.com/oauth20_token.srf",
    },
    ClientID:     config["ClientID"],
    ClientSecret: config["ClientSecret"],
    RedirectURL:  "http://localhost:8989",
}
なので例えば、空いているポートを使ってローカルサーバを立ち上げる事ができる。 l, err := net.Listen("tcp""localhost:0")
if err != nil {
    return "", err
}
defer l.Close()
/* l.Attr().String() がサーバのアドレスになる */

ここで http.ListenAndServe でサーバを起動するのではなく、net.Listen で作ったリスナでサーバを起動しているのは、アクセストークンを受け取った後にサーバを停止しないといけないからです。go1.8 では graceful shutdown がサポートされているが、まだ go1.8 はリリースされていない。次に以下の様にお決まりの手順で認証 URL へアクセスする。

stateBytes := make([]byte16)
_, err = rand.Read(stateBytes)
if err != nil {
    return "", err
}
state := fmt.Sprintf("%x", stateBytes)
err = open.Start(oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type""token")))
if err != nil {
    return "", err
}

ここで Microsoft の認証方式だとクエリ文字列ではなく、URL フラグメントとして帰ってくるのでサーバが直接アクセストークンを読み取れない。よって以下のハックを使ってフラグメントをクエリ文字列に変えて自分にループバックさせる。

quit := make(chan string)
go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
    if req.URL.Path == "/" {
        w.Write([]byte(`<script>location.href = "/close?" + location.hash.substring(1);</script>`))
    } else {
        w.Write([]byte(`<script>window.open("about:blank","_self").close()</script>`))
        w.(http.Flusher).Flush()
        quit <- req.URL.Query().Get("access_token")
    }
}))

/ でリクエストされるとフラグメント文字列をクエリ文字列に変えてアクセスし直す。そしてそのハンドラで access_token を取得して、ブラウザは自らのタブを消滅させる。なのでコマンドラインアプリのユーザは勝手に起動したブラウザでログインすればいいだけになる。全体のコードはおおよそ以下の様になる。

ただし Microsoft の API アクセストークンは発行後1時間で揮発するのでリフレッシュトークン等の処理は必要。

package main

import (
    "crypto/rand"
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net"
    "net/http"
    "os"
    "path/filepath"
    "runtime"
    "time"

    "golang.org/x/oauth2"

    "github.com/skratchdot/open-golang/open"
)

type Page struct {
    Title          string `json:"title"`
    CreatedByAppID string `json:"createdByAppId"`
    Links          struct {
        OneNoteClientURL struct {
            Href string `json:"href"`
        } `json:"oneNoteClientUrl"`
        OneNoteWebURL struct {
            Href string `json:"href"`
        } `json:"oneNoteWebUrl"`
    } `json:"links"`
    ContentURL                string    `json:"contentUrl"`
    LastModifiedTime          time.Time `json:"lastModifiedTime"`
    CreatedTime               time.Time `json:"createdTime"`
    ID                        string    `json:"id"`
    Self                      string    `json:"self"`
    ParentSectionOdataContext string    `json:"parentSection@odata.context"`
    ParentSection             struct {
        ID   string `json:"id"`
        Name string `json:"name"`
        Self string `json:"self"`
    } `json:"parentSection"`
}

func get(url, token string, val interface{}) error {
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return err
    }
    req.Header.Add("Authorization""Bearer "+token)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if val != nil {
        r := io.TeeReader(resp.Body, os.Stdout)
        return json.NewDecoder(r).Decode(val)
    }
    _, err = io.Copy(os.Stdout, resp.Body)
    return err
}

func getConfig() (stringmap[string]stringerror) {
    dir := os.Getenv("HOME")
    if dir == "" && runtime.GOOS == "windows" {
        dir = os.Getenv("APPDATA")
        if dir == "" {
            dir = filepath.Join(os.Getenv("USERPROFILE"), "Application Data""onenote")
        }
        dir = filepath.Join(dir, "onenote")
    } else {
        dir = filepath.Join(dir, ".config""onenote")
    }
    if err := os.MkdirAll(dir, 0700); err != nil {
        return ""nil, err
    }
    file := filepath.Join(dir, "settings.json")
    config := map[string]string{}

    b, err := ioutil.ReadFile(file)
    if err != nil && !os.IsNotExist(err) {
        return ""nil, err
    }
    if err != nil {
        config["ClientID"= "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
        config["ClientSecret"= "XXXXXXXXXXXXXXXXXXXXXXX"
    } else {
        err = json.Unmarshal(b, &config)
        if err != nil {
            return ""nil, fmt.Errorf("could not unmarshal %v%v", file, err)
        }
    }
    return file, config, nil
}

func getAccessToken(config map[string]string) (stringerror) {
    l, err := net.Listen("tcp""localhost:8989")
    if err != nil {
        return "", err
    }
    defer l.Close()

    oauthConfig := &oauth2.Config{
        Scopes: []string{
            "wl.signin",
            "wl.basic",
            "Office.onenote",
            "Office.onenote_update",
            "Office.onenote_update_by_app",
        },
        Endpoint: oauth2.Endpoint{
            AuthURL:  "https://login.live.com/oauth20_authorize.srf",
            TokenURL: "https://login.live.com/oauth20_token.srf",
        },
        ClientID:     config["ClientID"],
        ClientSecret: config["ClientSecret"],
        RedirectURL:  "http://localhost:8989",
    }

    stateBytes := make([]byte16)
    _, err = rand.Read(stateBytes)
    if err != nil {
        return "", err
    }

    state := fmt.Sprintf("%x", stateBytes)
    err = open.Start(oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("response_type""token")))
    if err != nil {
        return "", err
    }

    quit := make(chan string)
    go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        if req.URL.Path == "/" {
            w.Write([]byte(`<script>location.href = "/close?" + location.hash.substring(1);</script>`))
        } else {
            w.Write([]byte(`<script>window.open("about:blank","_self").close()</script>`))
            w.(http.Flusher).Flush()
            quit <- req.URL.Query().Get("access_token")
        }
    }))

    return <-quit, nil
}

func main() {
    file, config, err := getConfig()
    if err != nil {
        log.Fatal("failed to get configuration:", err)
    }
    if config["AccessToken"== "" {
        token, err := getAccessToken(config)
        if err != nil {
            log.Fatal("faild to get access token:", err)
        }
        config["AccessToken"= token
        b, err := json.MarshalIndent(config, """  ")
        if err != nil {
            log.Fatal("failed to store file:", err)
        }
        err = ioutil.WriteFile(file, b, 0700)
        if err != nil {
            log.Fatal("failed to store file:", err)
        }
    }

    var pages struct {
        Value []Page `json:"value"`
    }
    err = get("https://www.onenote.com/api/v1.0/me/notes/pages", config["AccessToken"], &pages)
    if err != nil {
        log.Fatal(err)
    }
    for _, item := range pages.Value {
        err = get("https://www.onenote.com/api/v1.0/me/notes/pages/"+item.ID+"/preview?includeIDs=true", config["AccessToken"], nil)
        if err != nil {
            log.Fatal(err)
        }
    }
}
Posted at by



2016/12/21


ここ最近、gops に関する記事を2つ書いた。

golang で書かれたプロセスのリソースを外部から監視/操作できる「gops」 - Qiita

この記事は [Go Advent Calendar 2016](http://qiita.com/advent-calendar/2016/go) の3日目の記事です。 # はじめに 今回は gops...

http://qiita.com/mattn/items/a92f69ff18eb5cbcdd59
稼働中のバッチを監視したくなったら Mackerel Custom Metrics が便利 - Qiita

この記事は [Mackerel Advent Calendar 2016](http://qiita.com/advent-calendar/2016/mackerel) の 12/13 日の記事です...

http://qiita.com/mattn/items/882a1924a1d706d127a2

実は gops には一つ注意点がある(あった)。プロファイル結果をファイルとして出力しているためにお掃除しているコードがあるのだけど、これを行う為に SIGINT をキャッチしてしまっている。なので既存で CTRL-C をハンドリングしていたコードがちゃんと働かなくなるという問題があった。

Allow starting the agent without installing a signal handler by cezarsa · Pull Request #19 · google/gops · GitHub

The idea of exposing this type was for forward compatibility. I wouldn't care about breaking API at ...

https://github.com/google/gops/pull/19

が、先ほど修正がマージされ

if err := agent.Listen(&agent.Options{NoShutdownCleanup: true}); err != nil {
    log.Fatal(err)
}

この様にオプションで終了時処理を無効にしつつ起動する事ができる様になった。これで安心してプロダクションコードでも使用できる様になった。

Posted at by



2016/12/02


追記: 修正が中途半端だったのでいったんリバートされ go1.9 で修正される事になりました。

UNIX に慣れている人であれば問題ないのですが、Windows で zip や tar.gz, tar.bz2, tar.xz を開くのは意外と不便で専用アーカイバをダウンロードしてきてインストールする必要があり、割かし不便なのですが archiver というツールを使うと以下のフォーマットを簡単に作成、展開できるようになります。

  • .zip
  • .tar
  • .tar.gz
  • .tgz
  • .tar.bz2
  • .tbz2
  • .tar.xz
  • .txz
  • .rar (開くのみ)

インストールは golang が入っていれば簡単です。

$ go get github.com/mholt/archiver/cmd/archiver

使い方も簡単で、圧縮は以下の手順。

$ archiver make [archive name] [input files...]

※形式は拡張子で判断します。

解凍は以下の手順。

$ archiver open [archive name] [destination]

シングルバイナリで動作するので、別の Windows にポンとバイナリ一つ持っていけば同様に動作するのが良いですね。

なお go1.7 の場合、zip のタイムスタンプが正しく保存されません。

Goでファイルをzip圧縮したときにタイムスタンプがずれる問題の回避策 - Qiita

#初めに Goでファイルをzip圧縮する方法と、それに伴う問題と、その回避の記事です。 (更にいい方法がありましたら、ご指摘よろしくお願い致します) #実験環境 go version go1.4.2 ...

http://qiita.com/yuki2006/items/0211421792bd788f8153
Golangで特定のディレクトリをZIP化する処理を書いた | SHINOFARAの日常

Go playground にも書いたけど、動かない 対象となるファイルが多すぎたか package main import ( "archive/zip" "fmt" "io" "io/ioutil...

https://log.shinofara.xyz/2016/06/30/golang%E3%81%A7%E7%89%B9%E5%AE%9A%E3%81%AE%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%88%E3%83%AA%E3%82%92zip%E5%8C%96%E3%81%99%E3%82%8B%E5%87%A6%E7%90%86%E3%82%92%E6%9B%B8%E3%81%84%E3%81%9F/
Golangのarchive/zipを使うと、タイムゾーンが強制UTCになる問題! | SHINOFARAの日常

archive/zip/struct.go#L170 このファイルの 170 func timeToMsDosTime(t time.Time) (fDate uint16, fTime uint16...

https://log.shinofara.xyz/2016/06/30/golang%E3%81%AEarchivezip%E3%82%92%E4%BD%BF%E3%81%86%E3%81%A8%E3%80%81%E3%82%BF%E3%82%A4%E3%83%A0%E3%82%BE%E3%83%BC%E3%83%B3%E3%81%8C%E5%BC%B7%E5%88%B6utc%E3%81%AB%E3%81%AA%E3%82%8B%E5%95%8F%E9%A1%8C/
ZIP化の時に、タイムスタンプが強制UTCになってしまう。 Issue #46 shinofara/stand GitHub

https://golang.org/src/archive/zip/struct.go#L171 の仕様上 強制的にUTC時刻としてみなされてしまっている。 その為、ロケール毎に時間を増減させないと...

https://github.com/shinofara/stand/issues/46

go1.8 でビルドすると正しく扱える様になります。go1.8 では上記の様なワークアラウンドが必要無くなります。逆に言うと、これらのワークアラウンドがあると問題が発生してしまいます。

Go 1.8 Release Notes - The Go Programming Language

DRAFT RELEASE NOTES - Introduction to Go 1.8 Go 1.8 is not yet released. These are work-in-progress ...

https://beta.golang.org/doc/go1.8#archive_zip
Gerrit Code Review

...

https://go-review.googlesource.com/#/c/18274/

こういった問題ではバージョン毎に処理を分ける必要があります。以下では go1.7 と go1.8 で動作を分けたい場合のコードを説明します。まずはワークアラウンドを行っている処理を抽出します。

func FileInfoHeader(fi os.FileInfo) (*zip.FileHeader, error) {
    size := fi.Size()
    fh := &zip.FileHeader{
        Name:               fi.Name(),
        UncompressedSize64: uint64(size),
    }

    // 現在のLocalを取得する。 
    local := time.Now().Local()

    //時刻のoffset(秒)を取得する。
    _, offset := local.Zone()

    //ファイルスタンプの時間に時差分を追加する。
    fh.SetModTime(fi.ModTime().Add(time.Duration(offset) * time.Second))
    fh.SetMode(fi.Mode())
    var uintmax = uint32((1 << 32- 1)
    if fh.UncompressedSize64 > uint64(uintmax) {
        fh.UncompressedSize = uintmax
    } else {
        fh.UncompressedSize = uint32(fh.UncompressedSize64)
    }
    return fh, nil
}

ここを別のソースファイルに切り出します。その際 build constraints に go1.7 と !go1.8 を指定します。

// +build go1.7
// +build !go1.8

package main

func FileInfoHeader(fi os.FileInfo) (*zip.FileHeader, error) {
    size := fi.Size()
    fh := &zip.FileHeader{
        Name:               fi.Name(),
        UncompressedSize64: uint64(size),
    }

    // 現在のLocalを取得する。 
    local := time.Now().Local()

    //時刻のoffset(秒)を取得する。
    _, offset := local.Zone()

    //ファイルスタンプの時間に時差分を追加する。
    fh.SetModTime(fi.ModTime().Add(time.Duration(offset) * time.Second))
    fh.SetMode(fi.Mode())
    var uintmax = uint32((1 << 32- 1)
    if fh.UncompressedSize64 > uint64(uintmax) {
        fh.UncompressedSize = uintmax
    } else {
        fh.UncompressedSize = uint32(fh.UncompressedSize64)
    }
    return fh, nil
}

これでこのファイルは go1.8 未満の場合しかビルドされなくなります。あとは go1.8 以降のバージョンでビルドされる様に go1.8 の build constraints が付いた、ワークアラウンドを実行しないファイルを用意します。

// +build go1.8

package foo

import (
    "archive/zip"
)

func FileInfoHeader(fi os.FileInfo) (*zip.FileHeader, error) {
    // ワークアラウンドしないコード
    size := fi.Size()
    fh := &zip.FileHeader{
        Name:               fi.Name(),
        UncompressedSize64: uint64(size),
    }

    fh.SetModTime(fi.ModTime())
    fh.SetMode(fi.Mode())
    var uintmax = uint32((1 << 32- 1)
    if fh.UncompressedSize64 > uint64(uintmax) {
        fh.UncompressedSize = uintmax
    } else {
        fh.UncompressedSize = uint32(fh.UncompressedSize64)
    }
    return fh, nil
}
これで、go1.7 未満であればワークアラウンドが実行されるバイナリが、go1.8 以降であればワークアラウンドを実行しないバイナリが生成されます。

build constraints については、みんなのGo言語にて説明と用法含めて書かれています。

みんなのGo言語【現場で使える実践テクニック】 みんなのGo言語【現場で使える実践テクニック】
松木雅幸, mattn, 藤原俊一郎, 中島大一, 牧 大輔, 鈴木健太, 稲葉貴洋
技術評論社 大型本 / ¥112 (2016年09月09日)
 
発送可能時間:

Posted at by