search
LoginSignup
61

More than 3 years have passed since last update.

posted at

updated at

Webアプリ初心者がGo言語でサーバサイド(3. セッションマネージャの実装)

はじめに

本記事は「Webアプリ初心者がGo言語でサーバサイド(2. パスワード認証機能の実装)」の続きです。

本記事で用いるサンプルコードはGitHubにアップしてあります。

セッション管理機能をつける

前回実装したパスワード認証機能ではコマンドラインに「Authentication Success!!」と元気よく表示されますが、直後にリダイレクトされるのでユーザのログイン情報は失われます。カゲロウの成虫より儚い。

本当にログイン情報は失われてしまうのでしょうか。ちょっと確認してみましょう。gin はコンテキストに状態を持たせることができますから、そこにユーザの情報を入れておいて、リダイレクト後に引き出せないか確認してみます。一見できそうではありますが。

実験してみたい人は、以下のようにソースコードの一部を書き換えてみてください。

routes/user_routes.go
func UserLogIn(ctx *gin.Context) {
    println("post/login")
    username := ctx.PostForm("username")
    password := ctx.PostForm("password")

    db := config.DummyDB()
    user, err := db.GetUser(username, password)
    if err != nil {
        println("Error: " + err.Error())
    } else {
        println("Authentication Success!!")
        println("  username: " + user.Username)
        println("  email: " + user.Email)
        println("  password: " + user.Password)
        user.Authenticate()
        ctx.Set("user", user) // <- ここ
    }

    ctx.Redirect(http.StatusSeeOther, "//localhost:8080/")
}
routes/routes.go
import (
    "sampleapp/config" // <- ここ

    "net/http"
    "github.com/gin-gonic/gin"
)

func Home(ctx *gin.Context) {
    // ここから
    buffer, exists := ctx.Get("user")
    if !exists {
        println("user data discarded.")
    } else {
        user := buffer.(*config.DummyUserModel)
        println("user data is taken over.")
        println("  username: " + user.Username)
        println("  email: " + user.Email)
    }
    // ここまで
    ctx.HTML(http.StatusOK, "index.html", gin.H{})
}

スクリーンショット 2018-11-02 21.09.18.png
無理ですね。ginのコンテキストはKeys map[string]interface{}という汎用のメンバを持っています。ctx.Next()で呼び出すことができるようなハンドラのチェーンの下流では、異なる関数の中であってもこのKeysctx.Set()でセットしたデータをctx.Get()で引き出すことができますが、GETリクエストとPOSTリクエストは異なるコンテキストで処理されているので、残念ながら値を持ち回すことができないのです。

儚くて悲しいのでセッションマネージャを作ります。例によって今回作るのはダミーで、一応セッションマネージャとしての仕事はしますが、Redisとの連携はしません。セッションマネージャの大まかな構造を下図に示します。
sessionstore_shrink.png
この図はgin-contrib/sessionsの実装を参考に整理したものです(gin-contrib/sessions およびこれにラップされている gorilla/sessions では上の図の cookieName, sessionID, sessionName の3種類の識別子をすべて name という変数で扱っていて若干読みづらいです)。

この話の肝はシステム全体で3種類の辞書が存在しているということであって、それさえ分かってしまえばあとは簡単です。

ひとつ目の辞書はクライアントのブラウザが保持している Cookie です。Cookie は小さなデータに名前をつけて、有効期限付きで保存することができます。Cookie は、それを保存させたサイトに HTTP Request を送るときに、そのリクエストのヘッダに自動的に付加されて送信されます。したがって、サーバが一度クライアントの Cookie に sessionID を保存させてしまえば、以降の通信ではクライアントが自動で sessionID を送ってきてくれるので、サーバはその情報を元にセッションを復元すればいいわけです。

ふたつ目の辞書は Session Manager によって管理される KVS です。クライアントから送られてきた sessionID の情報をリクエストハンドラが抜き出し、その sessionID を元にサーバに保存されているセッションを検索します。

みっつ目の辞書はgin.ContextのメンバであるKeysです。ginのリクエストハンドラは関数がチェーン状に連なっており、関数の垣根を越えて値を持ち回すにはKeysという汎用の値保存用メンバを使います。つまり処理の上流でKeysにセッションを保存しておけば、下流の処理ではそのセッションを使用できるということになります。汎用のメンバを使っているため、コンテキストには好きな名前をつけた複数種類のセッションを保存することができてしまいますが、基本的にはデフォルトのセッションが1種類あれば十分でしょう。

さて、実装に移る前にもう少し小言にお付き合いください。セッションはログイン状態を維持する機能を持っていますから、便利である反面、パスワードと同様に扱いには細心の注意を払わねばなりません。

Cookie にはサーバ側からの指定で有効期限を設定することができ、短いものではブラウザを閉じた時点で消去されます。この設定ではブラウザを閉じる度にログインし直すことになるためちょっと手間です。利便性を考慮して、ブラウザが閉じられても1週間くらいは Cookie に sessionID を保持したままにしておく設定のWebアプリはけっこうあります。Twitter や Qiita もそうでしょう。

ブラウザが閉じられても sessionID をクライアント側に残しておく設定は、便利である反面、ある種の危険性を孕みます。パスワードが玄関の鍵とすれば、sessionID は玄関のスペアキーみたいなものです。「鍵を持ち歩くのが面倒臭いからスペアキーを玄関の横の花瓶の下に隠しておいてこれを使うことにしよう」と言っているのが sessionID の仕組みです。実際、クライアント側に保存された Cookie の値はブラウザの開発者ツールなどで容易に覗くことができます。
スクリーンショット 2018-11-06 0.13.35.png
Cookie は伝送路上ではHTTPSでならば暗号化されるため、パスワードと同程度には安全です。つまり sessionID は「他の人があなたのパソコンを勝手にいじったりはしない」という前提においてある程度は安全だろう、という仕組みです。sessionID が盗まれるとセッションハイジャックという攻撃が成立してしまいます。決して sessionID をクエリに格納して送信する設計にしてはいけません。HTTPSでもクエリは暗号化されないため、通信の傍受で漏洩する可能性があります。また、sessionID はサーバが自動で発行しますが、推測されやすい方法で sessionID を発行してはいけません。連番などもってのほかです。

sessionID はクライアント側に使用可能な状態で保存され、毎回自動で送信されてしまうため、パスワードに比べてはるかに漏洩しやすいです。また、直接 sessionID を盗まなくとも CSRF(Cross-Site Request Forgeries)などの間接的な攻撃は成立しうるため、sessionID はそもそもあまり信用できない情報であるとみなしてシステムを設計するべきです。

具体的には、sessionID によって維持できるログイン状態に最高の権限を付与してはいけません。sessionID が合っていたから君は本人だね、パスワードの変更、メールアドレスの変更、アカウントの消去、なんでもやっていいよ、というのは危険なのです。そういった重要な操作を受け付ける場合には、必ずパスワードの再入力を求めたり、二段階認証を行ったりといった、別の認証方式での再認証を行わせるようにすべきです。つまり「スペアキーでリビングまでは入れるけど、寝室のドアの鍵は開けられないよ」といった設計にするのです。

以上のことを踏まえてセッション管理を実装していきましょう。今回の実装はgin-contrib/sessionsおよびgorilla/sessionsの中でも、本質的な機能だけを抽出したコードになっています。

sessionID には UUID を用います。UUIDはランダムに生成された128bitの数値で、確率的に、数億回生成したとしても衝突することはありません。たとえ毎秒ひとつUUIDを生成したとしても、UUIDの衝突を見る前にあなたの乗った車が衝突し、乗った飛行機が墜落し、宝くじの1等に当たることでしょう。それくらいの確率です。実際、UUIDバージョン4は Ruby の SecureRandom として使われています。crypto/crypto.goに以下の関数を追加してください。

crypto/crypto.go
func SecureRandom() string {
    return uuid.New().String()
}

func SecureRandomBase64() string {
    return base64.StdEncoding.EncodeToString(uuid.New().NodeID())
}

func LongSecureRandomBase64() string {
    return SecureRandomBase64() + SecureRandomBase64()
}

func MultipleSecureRandomBase64(n int) string {
    if n <= 1 {
        return SecureRandomBase64()
    }
    return SecureRandomBase64() + MultipleSecureRandomBase64(n - 1)
}

本当はSecureRandomBase64くらいあれば十分ですが、心配なら長いものを使ってください。続いてセッションの実装です。

sessions/dummy_sessions.go
package sessions

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/gorilla/context"
)

const (
    DefaultSessionName = "sample-sessions-default"
    DefaultCookieName = "samplesession"
)

func NewDummySession(store *DummyStore, cookieName string) *DummySession {
    return &DummySession{
        cookieName: cookieName,
        store: store,
        Values: map[string]interface{}{},
    }
}

type DummySession struct {
    cookieName string
    ID string
    store *DummyStore
    request *http.Request
    writer http.ResponseWriter
    Values map[string]interface{}
}

func StartSession(sessionName, cookieName string, store *DummyStore) gin.HandlerFunc {
    return func(ctx *gin.Context) {
        var session *DummySession
        var err error
        session, err = store.Get(ctx.Request, cookieName)
        if err != nil {
            session, err = store.New(ctx.Request, cookieName)
            if err != nil {
                println("Abort: " + err.Error())
                ctx.Abort()
            }
        }
        session.writer = ctx.Writer
        ctx.Set(sessionName, session)
        defer context.Clear(ctx.Request)
        ctx.Next()
    }
}

func StartDefaultSession(store *DummyStore) gin.HandlerFunc {
    return StartSession(DefaultSessionName, DefaultCookieName, store)
}

func GetSession(c *gin.Context, sessionName string) *DummySession {
    return c.MustGet(sessionName).(*DummySession)
}

func GetDefaultSession(c *gin.Context) *DummySession {
    return GetSession(c, DefaultSessionName)
}

// This returns the same result as s.session.Name()
func (s *DummySession) Name() string {
    return s.cookieName
}

func (s *DummySession) Get(key string) (interface{}, bool) {
    ret, exists := s.Values[key]
    return ret, exists
}

func (s *DummySession) Set(key string, val interface{}) {
    s.Values[key] = val
}

func (s *DummySession) Delete(key string) {
    delete(s.Values, key)
}

func (s *DummySession) Save() error {
    return s.store.Save(s.request, s.writer, s)
}

ginのハンドラチェーンの上流で予めStartSessionを実行しておくことで、ginのコンテキストにセッションをセットします。ハンドラチェーンの上流に新しいハンドラを挿入するには、gin.Engine.Use()を使います。

main.go
package main

import (
    "sampleapp/routes"
    "sampleapp/sessions" // <- ここと

    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.LoadHTMLGlob("views/*.html")
    router.Static("/assets", "./assets")

    // ここから
    store := sessions.NewDummyStore()
    router.Use(sessions.StartDefaultSession(store))
    // ここまでが追加したコード

    user := router.Group("/user")
    {
        user.POST("/signup", routes.UserSignUp)
        user.POST("/login", routes.UserLogIn)
    }

    router.GET("/", routes.Home)
    router.GET("/login", routes.LogIn)
    router.GET("/signup", routes.SignUp)
    router.NoRoute(routes.NoRoute)

    router.Run(":8080")
}

セッションマネージャの本体たるストアの実装はかなり隠蔽されていますが、実際は次のようなコードになっています。

sessions/dummy_store.go
package sessions

import (
    "sampleapp/crypto"

    "errors"
    "net/http"
)

type DummyStore struct {
    database map[string]interface{}
}

var kvs DummyStore

func init() {
    kvs.database = map[string]interface{}{}
}

func NewDummyStore() *DummyStore {
    return &kvs
}

func (s *DummyStore) NewSessionID() string {
    return crypto.LongSecureRandomBase64()
}

func (s *DummyStore) Exists(sessionID string) bool {
    _, r := s.database[sessionID]
    return r
}

func (s *DummyStore) Flush() {
    s.database = map[string]interface{}{}
}

func (s *DummyStore) Get(r *http.Request, cookieName string) (*DummySession, error) {
    cookie, err := r.Cookie(cookieName)
    if err != nil {
        // No cookies in the request.
        return nil, err
    }

    sessionID := cookie.Value
    // restore session
    buffer, exists := s.database[sessionID]
    if !exists {
        return nil, errors.New("Invalid sessionID")
    }

    session := buffer.(*DummySession)
    session.request = r
    return session, nil
}

func (s *DummyStore) New(r *http.Request, cookieName string) (*DummySession, error) {
    cookie, err := r.Cookie(cookieName)
    if err == nil && s.Exists(cookie.Value) {
        return nil, errors.New("sessionID already exists")
    }

    session := NewDummySession(s, cookieName)
    session.ID = s.NewSessionID()
    session.request = r

    return session, nil
}

func (s *DummyStore) Save(r *http.Request, w http.ResponseWriter, session *DummySession) error {
    s.database[session.ID] = session

    c := &http.Cookie{
        Name: session.Name(),
        Value: session.ID,
        Path: "/",
    }

    http.SetCookie(session.writer, c)
    return nil
}

func (s *DummyStore) Delete(sessionID string) {
    delete(s.database, sessionID)
}

Cookie は gin のコンテキストからgin.Context.Cookie、または http のリクエストからhttp.Request.Cookieによって簡単に取得することができます(gin のコンテキストが gin.Context.Request にリクエストを保持しているのでどちらを使っても同じことです)。また、クライアント側に Cookie をセットするにはSetCookieを使います。

sessionID から取得したセッションを、ハンドラの一連の処理の上流でコンテキストにセットしたので、下流での処理においてはコンテキストにセッションが復元された状態で流れてくることになります。これによって、リダイレクトなどに際しても、あたかも続けて処理しているかのように状態を扱うことができるようになります。

routes/user_routes.go
package routes

import (
    "sampleapp/config"
    "sampleapp/sessions"

    "net/http"
    "github.com/gin-gonic/gin"
)

func UserSignUp(ctx *gin.Context) {
    println("post/signup")
    username := ctx.PostForm("username")
    email := ctx.PostForm("emailaddress")
    password := ctx.PostForm("password")
    passwordConf := ctx.PostForm("passwordconfirmation")

    if password != passwordConf {
        println("Error: password and passwordConf not match")
        ctx.Redirect(http.StatusSeeOther, "/")
        return
    }

    db := config.DummyDB()
    if err := db.SaveUser(username, email, password); err != nil {
        println("Error: " + err.Error())
        ctx.Redirect(http.StatusSeeOther, "/")
        return
    }

    println("Signup success!!")
    println("  username: " + username)
    println("  email: " + email)
    println("  password: " + password)
    user, err := db.GetUser(username, password)
    if err != nil {
        println("Error: while loading user: " + err.Error())
        ctx.Redirect(http.StatusSeeOther, "/")
        return
    }

    session := sessions.GetDefaultSession(ctx)
    session.Set("user", user)
    session.Save()
    println("Session saved.")
    println("  sessionID: " + session.ID)
    ctx.Redirect(http.StatusSeeOther, "/")
}

func UserLogIn(ctx *gin.Context) {
    println("post/login")
    username := ctx.PostForm("username")
    password := ctx.PostForm("password")

    db := config.DummyDB()
    user, err := db.GetUser(username, password)
    if err != nil {
        println("Error: " + err.Error())
        ctx.Redirect(http.StatusSeeOther, "/")
        return
    }

    println("Authentication Success!!")
    println("  username: " + user.Username)
    println("  email: " + user.Email)
    println("  password: " + user.Password)
    session := sessions.GetDefaultSession(ctx)
    session.Set("user", user)
    session.Save()
    user.Authenticate()

    println("Session saved.")
    println("  sessionID: " + session.ID)
    ctx.Redirect(http.StatusSeeOther, "/")
}

routes/routes.goは書き換えたHome関数だけ記載します。

routes/routes.go
package routes

import (
    "sampleapp/sessions"
    "sampleapp/config"

    "net/http"
    "github.com/gin-gonic/gin"
)

func Home(ctx *gin.Context) {
    session := sessions.GetDefaultSession(ctx)
    buffer, exists := session.Get("user")
    if exists {
        user := buffer.(*config.DummyUserModel)
        println("Home sweet home")
        println("  sessionID: " + session.ID)
        println("  username: " + user.Username)
        println("  email: " + user.Email)
    } else {
        println("Unhappy home")
        println("  sessionID: " + session.ID)
    }

    session.Save()
    ctx.HTML(http.StatusOK, "index.html", gin.H{})
}

前回、サインアップを処理したハンドラからホームへとリダイレクトするとユーザの情報は失われてしまいました。今回の作業によってログイン状態を維持したままホーム画面へと遷移できていれば、ログに「Home sweet home(憩いの我が家)」と表示されます。
スクリーンショット 2018-11-06 0.48.40.png
うむ。なんとかなったようじゃな。ユニットテストとかしてないのでバグが残ってるかもしらんが。

ログインの有無によってページを切り替える

リダイレクト後にログインしたユーザの名前が表示されるようにして、この記事を締めくくりましょう。まずはいくつかのソースファイルを書き換えたり、関数を追加したりしましょう。これらは主にログアウトの処理を行うためのものです。

main.go
func main() {
    router := gin.Default()
    router.LoadHTMLGlob("views/*.html")
    router.Static("/assets", "./assets")

    store := sessions.NewDummyStore()
    router.Use(sessions.StartDefaultSession(store))

    user := router.Group("/user")
    {
        user.POST("/signup", routes.UserSignUp)
        user.POST("/login", routes.UserLogIn)
        user.POST("/logout", routes.UserLogOut) // <-ここ
    }

    router.GET("/", routes.Home)
    router.GET("/login", routes.LogIn)
    router.GET("/signup", routes.SignUp)
    router.NoRoute(routes.NoRoute)

    router.Run(":8080")
}
routes/user_routes.go
func UserLogOut(ctx *gin.Context) {
    session := sessions.GetDefaultSession(ctx)
    session.Terminate()
    ctx.Redirect(http.StatusSeeOther, "/")
}
routes/routes.go
func Home(ctx *gin.Context) {
    var user *config.DummyUserModel

    session := sessions.GetDefaultSession(ctx)
    buffer, exists := session.Get("user")
    if !exists {
        println("Unhappy home")
        println("  sessionID: " + session.ID)
        session.Save()
        ctx.HTML(http.StatusOK, "index.html", gin.H{})
        return
    }

    user = buffer.(*config.DummyUserModel)
    println("Home sweet home")
    println("  sessionID: " + session.ID)
    println("  username: " + user.Username)
    println("  email: " + user.Email)

    session.Save()
    ctx.HTML(http.StatusOK, "index.html", gin.H{
        "isLoggedIn": exists,
        "username": user.Username,
        "email": user.Email,
    })
}
sessions/dummy_sessions.go
func (s *DummySession) Terminate() {
    s.store.Delete(s.ID)
}

テンプレートの条件分岐によって表示内容を切り替えます。

views/index.html
{{ template "head" }}
{{ if .isLoggedIn }}
    <body>
        <h1>Welcome home {{ .username }}.</h1>
        <h2>Your Email address is {{ .email }}</h2>
        <form id="signout-form" action="/user/logout" method="POST">
            <input type="submit" value="ログアウト">
        </form>
    </body>
{{ else }}
    <body>
        <h1>Welcome to SampleApp.</h1>
        <ul>
            <li><a href="//localhost:8080/login">login</a>
            <li><a href="//localhost:8080/signup">signup</a>
        </ul>
    </body>
{{ end }}
{{ template "foot" }}

スクリーンショット 2018-11-06 1.42.38.png
スクリーンショット 2018-11-06 1.43.22.png

Webアプリに必要な機能が段々揃ってきました。あと2回か3回ほどでMySQLとRedisを使用して、ハリボテではないきちっとした構成にしたいと思います。

次回はしばしお待ちください。

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
What you can do with signing up
61