Edited at

goaでお手軽google loginを行うミドルウェアを作ってみました。

More than 1 year has passed since last update.

goaで手軽にwebAPIをつくれるのはいいのですが、ユーザ認証が意外と面倒です。

かと言って個人開発でauth0とか使ってられないし。

googleアカウントで認証を行いtokenを発行して以降、それを使って

通信を行いたいです。できるだけ手軽に。

そういうのが簡単に実現できるミドルウェアを作ってみました。

GAE/goにも対応しています。

セキュリテイ上の懸念があるようでしたらご指摘いただければ幸いです。

以下のシーケンスでやり取りします。

stateのチェックとapiと通信を行うためのトークンとして

JWTを使っています。

JWTとはjsonをbase64エンコードしたものですがそれに署名を付けて改ざんの検知ができるようにしたものです。

js-sequence-diagrams_by_bramp.png

最初のリダイレクトの際にstateに本来はランダムな文字列を格納するのですが

今回はそこにJWTを仕込んでいます。

そうすることでstate用のランダム文字列をdbに一時保存する処理が不要となります。


実際の使い方

先ず今回作ったミドルウェアを読み込みます

$ go get github.com/m0a/goagooglelogin

先ずはJWTをサポートする設計を行います


design/design.go

package design

import (
. "github.com/goadesign/goa/design"
. "github.com/goadesign/goa/design/apidsl"
)

var JWT = JWTSecurity("jwt", func() {
Header("Authorization")
Scope("api:access", "API access") // Define "api:access" scope
})

// web static file serve
var _ = Resource("serve", func() {
Files("/", "./static/index.html")
Files("/static/*filepath", "./static")
})

// Resource jwt uses the JWTSecurity security scheme.
var _ = Resource("jwt", func() {
Description("This resource uses JWT to secure its endpoints")
DefaultMedia(SuccessMedia)

Security(JWT, func() { // Use JWT to auth requests to this endpoint
Scope("api:access") // Enforce presence of "api" scope in JWT claims.
})

Action("secure", func() {
Description("This action is secured with the jwt scheme")
Routing(GET("/jwt"))
Response(OK, SecureMedia)
Response(Unauthorized)
})

Action("unsecure", func() {
Description("This action does not require auth")
Routing(GET("/jwt/unsecure"))
NoSecurity() // Override the need for auth
Response(OK)
})
})

var SecureMedia = MediaType("application/vnd.goa.examples.security.secure+json", func() {
Attributes(func() {
Attribute("Name", String)
Attribute("Email", String)
Attribute("Image", String)
})
View("default", func() {
Attribute("Name")
Attribute("Email")
Attribute("Image")
})
})

var SuccessMedia = MediaType("application/vnd.goa.examples.security.success", func() {
Description("The common media type to all request responses for this example")
TypeName("Success")
Attributes(func() {
Attribute("ok", Boolean, "Always true")
Required("ok")
})
View("default", func() {
Attribute("ok")
})
})


上記はJWTの例の丸コピです。api設計としては

methods
エンドポイント
目的

get
http://XXX/api/jwt
セキュリティ有り

get
http://XXX/api/jwt/unsecure
セキュリティ無し

となっています。http://XXX/api/jwt にアクセスしたら自分の情報をdbから拾いに行くようにします。


実装

先ずはdbとしてメモリにためておくように構造体を作っておきます


models.go

package main

import "time"

type Account struct {
GoogleUserID string
Image []byte
Email string
Name string
Created time.Time
}


実際の実装は以下のとおりです


main.go

package main

import (
"context"
"fmt"
"io/ioutil"
"net/http"
"os"
"time"

oauth2 "google.golang.org/api/oauth2/v2"

jwt "github.com/dgrijalva/jwt-go"
"github.com/goadesign/goa"
"github.com/goadesign/goa/middleware"
"github.com/m0a/goagooglelogin"
"github.com/m0a/goagooglelogin/examples/simple/app"
"github.com/m0a/goagooglelogin/examples/simple/controllers"
)

var (
// ErrUnauthorized is the error returned for unauthorized requests.
ErrUnauthorized = goa.NewErrorClass("unauthorized", 401)
)

func main() {
// Create service
service := goa.New("Secure API")

// Mount middleware
service.Use(middleware.RequestID())
service.Use(middleware.LogRequest(true))
service.Use(middleware.ErrorHandler(service, true))
service.Use(middleware.Recover())

accounts := map[string]controllers.Account{}

conf := &goagooglelogin.DefaultGoaGloginConf
conf.LoginSigned = "eeee33344445"
conf.StateSigned = "sddwsdfaseq2"
conf.GoogleClientID = os.Getenv("OPENID_GOOGLE_CLIENT")
conf.GoogleClientSecret = os.Getenv("OPENID_GOOGLE_SECRET")

conf.CreateClaims = func(googleUserID string,
userinfo *oauth2.Userinfoplus, tokenInfo *oauth2.Tokeninfo, ctx context.Context) (claims jwt.Claims, err error) {
resp, err := http.Get(userinfo.Picture)
if err != nil {
return nil, err

}
defer resp.Body.Close()
picture, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}

fmt.Println(len(picture))

// sample save code
_, ok := accounts[googleUserID]
if !ok {
account := controllers.Account{
GoogleUserID: googleUserID,
Image: picture,
Email: userinfo.Email,
Name: userinfo.Name,
Created: time.Now(),
}
accounts[googleUserID] = account
}

return goagooglelogin.MakeClaim("api:access", googleUserID, 10), nil
}

// Mount security middlewares
app.UseJWTMiddleware(service, goagooglelogin.NewJWTMiddleware(conf, app.NewJWTSecurity()))

goagooglelogin.MountControllerWithConfig(service, conf)
// Mount "JWT" controller
c1 := controllers.NewJWTController(service, &accounts)
app.MountJWTController(service, c1)

c2 := controllers.NewServeController(service)
app.MountServeController(service, c2)

// Start service
if err := service.ListenAndServe(":8080"); err != nil {
service.LogError("startup", "err", err)
}
}


順に説明しますと


main.go


conf := &goagooglelogin.DefaultGoaGloginConf
conf.LoginSigned = "xsdsafasd"
conf.StateSigned = "sddwaseq2"
conf.GoogleClientID = os.Getenv("OPENID_GOOGLE_CLIENT")
conf.GoogleClientSecret = os.Getenv("OPENID_GOOGLE_SECRET")

conf.CreateClaims = func(googleUserID string,
userinfo *oauth2.Userinfoplus, tokenInfo *oauth2.Tokeninfo, ctx context.Context) (claims jwt.Claims, err error) {
/* 省略 */
}


にて各種設定を行っています

conf.LoginSignedconf.StateSignedはそれぞれJWTを作る際のキーとなります。

conf.GoogleClientIDconf.GoogleClientSecretはGoogleアカントアクセスのためのキーとシークレットとなります。

conf.CreateClaimsにTokenのClaim作成処理とDBへの保存処理を記述しておきます。

Claim作成処理はgoagooglelogin.MakeClaimでほとんど行いますので、

基本的にはDBの保存処理を書くことになります。

上記設定は一つの構造体に集約してます


type CreateClaimFunction func(
googleUserID string,
userinfo *oauth2.Userinfoplus,
tokenInfo *oauth2.Tokeninfo,
ctx context.Context) (jwt.Claims, error)

type (
// GoaGloginConf middleware config
GoaGloginConf struct {
LoginURL string // defualt: /login
CallbackURL string // default: /oauth2callback
StateSigned string // state JWT key
LoginSigned string // login JWT key
GoogleClientID string
GoogleClientSecret string
CreateClaims CreateClaimFunction
ExtensionIDs []string
}
)

loginURLCallbackURLはデフォルトのまま使ったほうがいいと思います。

上記設定であれば最初に/lgoin?next_url=/でログイン処理が開始します。

next_urlはログイン完了後sessionStorageにトークンを保存した後にリダイレクトするurlとなります。

あとはコールバック先としてログイン画面表示後に、/oauth2callbackに遷移します。


コントローラーからのアクセス

変更点のみ記述します

// NewJWTController creates a jwt controller.

func NewJWTController(service *goa.Service, ac *map[string]Account) *JWTController {
return &JWTController{
Controller: service.NewController("JWTController"),
Accounts: ac,
}
}

// Secure runs the secure action.
func (c *JWTController) Secure(ctx *app.SecureJWTContext) error {
jwtContext := jwt.ContextJWT(ctx)
claims, ok := jwtContext.Claims.(jwtgo.MapClaims)
if !ok {
return ctx.Unauthorized()
}
googleID, ok := claims["sub"].(string)
if !ok {
return ctx.Unauthorized()
}

if c.Accounts == nil {
return ctx.Unauthorized()
}
account, ok := (*c.Accounts)[googleID]
if !ok {
return ctx.Unauthorized()
}

img := base64.StdEncoding.EncodeToString(account.Image)
res := app.GoaExamplesSecuritySecure{
Name: &account.Name,
Email: &account.Email,
Image: &img,
}
return ctx.OK(&res)
}

以下の処理にてJWTからgoogleIDを取得します。

    jwtContext := jwt.ContextJWT(ctx)

claims, ok := jwtContext.Claims.(jwtgo.MapClaims)
if !ok {
return ctx.Unauthorized()
}
googleID, ok := claims["sub"].(string)
if !ok {
return ctx.Unauthorized()
}

あとはDBからそのIDを使って必要な情報を引き出すことで取得します。

この実装全体は以下においています

https://github.com/m0a/goagooglelogin/tree/master/examples


参考情報

あとで記述