LoginSignup
7
9

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-05-27

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を使って必要な情報を引き出すことで取得します。
この実装全体は以下においています

参考情報

あとで記述

7
9
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
7
9