技術向上

プログラミングの学び、気になるテクノロジーやビジネストレンドを発信

Sessionの期限更新と清掃【Go】

処理を実行するごとにsessionの有効期限を更新し、ログアウト時に、他のユーザーに関するsession情報も含めて、 一定時間を超えたsessionを削除する機能を用意します。

過去の記事を基にしています。
Session【Go】 - 技術向上

bcryptでパスワードをhash化【Go】 - 技術向上


main.go (package main)

type user struct {
    UserName, First, Last, Role string
    Password                    []byte
}

type session struct {
    un           string
    lastActivity time.Time    // 最終処理時間を保持
}

var tpl *template.Template
var dbUsers = make(map[string]user)
var dbSessions = make(map[string]session)
var dbSessionsCleaned time.Time    // 最後にdbSessionsを清掃した時間を保持

const sessionLength int = 30    // sessionの期限を30(秒)に指定(MaxAgeの指定に使う)

func init() {
    tpl = template.Must(template.ParseGlob("templates/*"))
    dbSessionsCleaned = time.Now()    // 初期化
}

func main() {
    http.HandleFunc("/", index)
    http.HandleFunc("/bar", bar)
    http.HandleFunc("/signup", signup)
    http.HandleFunc("/login", login)
    http.HandleFunc("/logout", logout)
    http.Handle("/favicon.ico", http.NotFoundHandler())
    http.ListenAndServe(":8080", nil)
}

func index(w http.ResponseWriter, req *http.Request) {
    u := getUser(w, req)
    tpl.ExecuteTemplate(w, "index.gohtml", u)
}

func bar(w http.ResponseWriter, req *http.Request) {
    if !alreadyLoggedIn(w, req) {
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }

    u := getUser(w, req)
    if u.Role != "007" {
        http.Error(w, "Must be 007", http.StatusForbidden)
        return
    }
    tpl.ExecuteTemplate(w, "bar.gohtml", u)
}

func signup(w http.ResponseWriter, req *http.Request) {
    if alreadyLoggedIn(w, req) {
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }

    if req.Method == http.MethodPost {
        un := req.FormValue("username")
        p := req.FormValue("password")
        f := req.FormValue("firstname")
        l := req.FormValue("lastname")
        r := req.FormValue("role")

        if _, ok := dbUsers[un]; ok {
            http.Error(w, "Username already taken.", http.StatusForbidden)
            return
        }

        c := makeSession(w)
        c.MaxAge = sessionLength    // 有効期限は30秒
        http.SetCookie(w, c)
        dbSessions[c.Value] = session{
            un:           un,
            lastActivity: time.Now(),    // 最終処理時間を更新
        }
        bs, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.MinCost)    // 外部パッケージを用いたハッシュ化
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        u := user{
            UserName: un,
            First:    f,
            Last:     l,
            Role:     r,
            Password: bs,    // ハッシュ化済パスワード
        }
        dbUsers[un] = u    // テーブルに格納
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }
    tpl.ExecuteTemplate(w, "signup.gohtml", nil)
}

func login(w http.ResponseWriter, req *http.Request) {
    if alreadyLoggedIn(w, req) {
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }
    if req.Method == http.MethodPost {
        un := req.FormValue("username")
        p := req.FormValue("password")

        u, ok := dbUsers[un]    // 存在したらokはtrue
        if !ok {
            http.Error(w, "Username or/and Password do not match.", http.StatusForbidden)
            return
        }

        err := bcrypt.CompareHashAndPassword(u.Password, []byte(p))    // 外部パッケージを用いた、ハッシュ化済パスワードとbyte配列の照合
        if err != nil {    // 合わなかった場合
            http.Error(w, "Username or/and Password do not match.", http.StatusForbidden)
            return
        }
        c := makeSession(w)
        c.MaxAge = sessionLength    // 有効期限は30秒
        http.SetCookie(w, c)
        dbSessions[c.Value] = session{
            un:           un,
            lastActivity: time.Now(),    // 最終処理時間を更新
        }
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }
    tpl.ExecuteTemplate(w, "login.gohtml", nil)
}

func logout(w http.ResponseWriter, req *http.Request) {
    if !alreadyLoggedIn(w, req) {
        http.Redirect(w, req, "/", http.StatusSeeOther)
        return
    }
    c, _ := req.Cookie("session")
    delete(dbSessions, c.Value)    // 自身のsessionを削除
    c.MaxAge = -1
    http.SetCookie(w, c)    // Cookieを削除

    if time.Now().Sub(dbSessionsCleaned) > (time.Second * 30) {
        go cleanSessions()    // dbSessions全体の清掃を実行
    }

    http.Redirect(w, req, "/", http.StatusSeeOther)
    return
}


session.go (package main)

func getUser(w http.ResponseWriter, req *http.Request) user {
    c, err := req.Cookie("session")
    if err != nil {
        c = makeSession(w)
    }
    var u user
    if ss, ok := dbSessions[c.Value]; ok {    // dbSessionsに保存したユーザー名をキーに、dbUsersからuser情報を取得するため
        u = dbUsers[ss.un]
    }
    return u
}

func alreadyLoggedIn(w http.ResponseWriter, req *http.Request) bool {
    c, err := req.Cookie("session")
    if err != nil {
        return false
    }
    ss, ok := dbSessions[c.Value]
    if ok {
        ss.lastActivity = time.Now()    // 最終処理時間を更新
        dbSessions[c.Value] = ss
    }
    _, ok = dbUsers[ss.un]    // 存在していたら(ログインしていたら)okはtrue
    c.MaxAge = sessionLength
    http.SetCookie(w, c)
    return ok
}

func makeSession(w http.ResponseWriter) *http.Cookie {
    id, err := uuid.NewV4()    // 外部パッケージを用いたunique idの生成
    if err != nil {
        log.Fatal(err)
    }
    return &http.Cookie{
        Name:  "session",
        Value: id.String(),    // 文字列化
    }
}

func cleanSessions() {
    fmt.Println("BEFORE CLEAN")    // デモ用
    for k, v := range dbSessions {
        if time.Now().Sub(v.lastActivity) > (time.Second * 30) {    // デモ用のため、時間を短くしている
            delete(dbSessions, k)
        }
    }
    dbSessionsCleaned = time.Now()
    fmt.Println("AFTER CLEAN")   // デモ用
}


ログアウト処理時に、定期的にdbSessionsを清掃する処理をgroutineで書いています。
mapの要素の「削除」の場合には、競合によるエラーは発生しないため、この方法で問題ありません。
更新の場合は、複数ユーザーが同時にログアウトを行なうと、競合エラーが発生する可能性があります。

各種.gohtmlの紹介はしませんが、formのinput要素をgoファイルと合わせれば、後は自由です。

Session【Go】 - 技術向上

bcryptでパスワードをhash化【Go】 - 技術向上