Goでログインとサインアップの認証機能を作る
この記事で作ったWebアプリケーションをもとに作る
やること
一旦認証基盤を作る
- RDBとの接続処理を書く
- てことはdockerを導入する
- MySQLパッケージも導入する
- usersテーブルを作る。
- /sign_up, /loginのページを作る。同じページにpostリクエストする感じで良いか。
- sign_upフォームはパスワードとパスワードカンファとemailと名前
- loginページは、パスワードとemail
- バリデーションの仕組みを作って、通らないなら、普通に再度フォームを表示する
- サインアップまたは認証が成功したなら、リダイレクトさせる
- クッキーを添付して、各ユーザーに対応したページが毎回表示できているかをチェックする
セッションストレージの作成と、クッキーにsession_idを入れて、セッションの仕組みを作る
これができたら、sessionストレージにredisを使った場合のやり方もやってみる
サインアップは成功した。ログインとログアウトの仕組みを作るか。
あと、sessionストレージにredis、session_idに送信にクッキーを使って、セッション管理できるようにする。
ログイン機能は作った。あとはログアウトか。
クッキーに何かしらデータを入れる際は、生データは避けたいな。エンコードしたい。でもエンコードも結局デコードできちゃうからだったら、user_idをクッキーとして送るんじゃなくて、session_idとかをクッキーとして送りたい。
ログイン機能の流れ
- フォームにメアドとパスワードを入力する
- サーバー側へリクエストを出す
- (本来なら、リクエスト値をフロントとサーバーでバリデーションしたいが、今回は省略)
- サーバー側でメアドをもとにユーザーレコードを特定。このユーザーレコードはパスワードダイジェストを持っている
- パスワードからパスワードダイジェストを作って、ユーザーレコードのパスワードダイジェストと比較する
- 同じなら、マイページへリダイレクトする。その際クッキーも付与する(セッションストレージ使うならセッションのレコードも作る)。リダイレクトしないでマイページを表示しちゃうと、フォームのパスがURLバーに表示されつつマイページが表示されるからすごい違和感が出ちゃう。同じじゃないなら、ログインページをレスポンスする
サインアップ機能の流れ
- フォームに名前、メアド、パスワード、パスワードカンファを入力する
- サーバー側へリクエストを出す
- (本来なら、リクエスト値をフロントとサーバーでバリデーションしたいが、今回は省略)
- サーバー側でメアドをもとにユーザーレコードを特定。もし特定できちゃったら、emailがユニークではないので、サインアップフォームを返す。
- パスワードダイジェストを作成して、渡された情報をもとにユーザーレコードをインサートする
- 特に問題がなければ、マイページにリダイレクトする。その際にクッキーも付与する。リダイレクトしないとurlバーに表示されているパスが/sign_upなのにマイページが表示されちゃって違和感がある
リダイレクトする際は必ずLocationヘッダーをつける
セッションの機構を作るか。
終わったらログアウトも作るか。
Dockerfileでcdしたいなら、WORKDIRで絶対パスを指定しないとダメ。
そうか、クッキーにセッションidだけ入れるとなると、サーバー側でリクエストしてきたクライアントがどのユーザーに対応しているのかを特定する必要がある。セッションidだけだと特定できない。なので、セッションストレージにセッションidとユーザーidの組み合わせを登録しておく。クライアントからリクエストが来たら、クッキーからセッションidを取得して、そのセッションidを元にセッションストレージ(Redisを使うことが多い)からユーザーidを取得する。そうすれば、サーバー側でクラインアントを特定できる(結果的にサーバーはクライアントとのセッションを保持できる)。もしくはクッキーのvalueでuse_idを入れちゃえばセッションストレージをサーバー側で用意することはなくなる。この場合、クッキーのvalueをセッションストレージとして扱っている。この場合は必ずサーバー側でしかデコードできないような暗号化をする必要がある。
クライアントからしたら一つのサーバーにアクセスすれば良い。
サーバーからしたから複数のクライアントからアクセスが来るので、どのクライアントかを識別できないといけない。そこでクッキーが使われる
この記事、ログイン後にクッキーを使っていないから、
セッションが保持されないのか。なるほどね。認証ロジックを作っているだけか。
リロードしたらクライアントからクッキーが飛ばされないから、もう一度ログインしろってなるねこれだと。ログイン後のリダイレクトした時にしか、ログイン成功ページにいけないって感じか。てなるとやっぱ認証後にクッキー送信した方がユーザーがログイン状態を保持できるようになるから良いね。
サーバーサイドでは、
- 認証後にクッキーを送信するのと、セッションストレージにデータを入れる
- 毎回リクエストでクッキーが送られてきて、そのデータからユーザーを特定する処理を実行する
セッションを実現するためには、サーバーサイドで上の処理をやる必要があるな
ここまでで参考にした記事
これいつか読む。アプリケーションサーバー自体にセッションストレージを作る際に参考になりそう。
コントローラは名前空間でグルーピングしたいな。抽象的な名前でグルーピングするイメージ。
これはコントローラのファイルが増えると結構ヤバくなるんよな。
Goで自作ミドルウェアを作成して、なんとなく分かった。
ミドルウェアとは、リクエストをパースして生成したリクエストオブジェクトをコントローラアクションに渡す前に実行する処理(自作したフレームワークの場合、ミドルウェアのレスポンスでコントローラを返す)。もしくは、コントローラアクションが返すレスポンスオブジェクトに対して実行する処理(同じく自作したフレームワークの場合、ミドルウェアのレスポンスでコントローラを返す)。
↓ CheckLoginミドルウェア
package middleware
import (
"log"
"net/url"
"github.com/yukiHaga/web_server/src/internal/app/controller"
"github.com/yukiHaga/web_server/src/internal/app/model"
"github.com/yukiHaga/web_server/src/pkg/henagin/http"
)
type CheckLoginController struct {
nextAction func(request *http.Request) *http.Response
}
// ログインユーザー専用のページをリクエストした場合、ユーザーidのクッキーがないならログインページにリダイレクトさせる
func (c CheckLoginController) Action(request *http.Request) *http.Response {
if cookie, isThere := request.GetCookieByName("user_id"); isThere {
id, _ := url.QueryUnescape(cookie.Value)
_, err := model.FindUserById(id)
// user_idクッキーはあるけど不正な場合
// ログインページにリダイレクトさせる
if err != nil {
log.Printf("fail to find error: %v", err)
response := http.NewResponse(
http.VersionsFor11,
http.StatusRedirectCode,
http.StatusReasonRedirect,
request.TargetPath,
[]byte{},
)
response.SetRedirectHeader("/login")
return response
} else {
// user_idクッキーがあってかつ有効な場合
// ログインユーザー専用ページのアクションを呼び出す
c.nextAction(request)
}
} else {
// user_idクッキーがそもそもない場合
// ログインページにリダイレクトさせる
response := http.NewResponse(
http.VersionsFor11,
http.StatusRedirectCode,
http.StatusReasonRedirect,
request.TargetPath,
[]byte{},
)
response.SetRedirectHeader("/login")
return response
}
return c.nextAction(request)
}
// ミドルウェア
// / ダミーコントローラとダミーアクションを作って、ダミーアクションの中で元々のコントローラのアクションを呼び出して、最終的にレスポンスを返せばOK
// goではメソッドを書き換えるのはできなかった
func CheckLogin(c controller.Controller) controller.Controller {
return CheckLoginController{nextAction: c.Action}
}
ミドルウェアはルーティングでコントローラを包んで使う。だから今回自作したフレームワークの場合、ミドルウェアはコントローラを返す必要がある
var Routing = []*pattern.URLPattern{
pattern.NewURLPattern("/now", controller.NewNow()),
pattern.NewURLPattern("/show_request", controller.NewShowRequest()),
pattern.NewURLPattern("/parameters", controller.NewParameters()),
pattern.NewURLPattern("/users/:id/profile", controller.NewUserProfile()),
pattern.NewURLPattern("/cookie_request", controller.NewCookieRequest()),
// ユーザーリクエストをパースして作成したリクエストオブジェクトをコントローラアクションに渡す前に、認証のミドルウェアを挟み込む
pattern.NewURLPattern("/sign_up", middleware.CheckLogout(controller.NewSignUp())),
pattern.NewURLPattern("/login", middleware.CheckLogout(controller.NewLogin())),
pattern.NewURLPattern("/mypage", middleware.CheckLogin(controller.NewMyPage())),
}
ミドルウェアを作ることで、複数のエンドポイントのリクエストレスポンス処理の前後で必要な処理を一つのミドルウェアとして抽象化できる。コントローラアクションに同じ処理を何回も書かなくて済むし、コントローラの詳細を知らなくても良くなるし、コントローラの処理を変更しなくて済む。
ミドルウェアを作る時に参考にした記事
Laravelのミドルウェアが地味に参考になる
流れ的にはこんな感じか
(本来ならHTTPリクエストをパースしてリクエストオブジェクトを作ってルーターに渡すけど、そこはあえて省略した。あとHTTPレスポンスの前段階でレスポンスオブジェクトをシリアライズする必要がある)
HTTPリクエスト → router → middleware → controller → view → HTTPレスポンス
HTTPリクエスト → router → controller → view → middleware → HTTPレスポンス
「パース」の反対は「シリアライズ(serialize)」です。
パースは、データを解析し、それをコンピュータが理解できる形式に変換することを指します。例えば、テキストを構造化されたデータに変換する操作などがパースの一例です。
一方、シリアライズは、データをバイト列やテキストなどの連続した形式に変換し、それをファイルに書き込んだり、ネットワークを介して送信したりすることを指します。シリアライズされたデータは、後で再び読み込まれたり、別のプログラムやシステムで利用されたりすることがあります。
簡単に言えば、パースはデータを解釈して内部的に利用するための処理であり、シリアライズはデータを外部形式に変換して保存や転送を行う処理です。
この図がわかりやすい
ミドルウェアは実は少し難しい。StackPHPの下の図を見てください。あなたのアプリケーション(ルーティング、コントローラ、ビジネスロジック)が中央の緑色の円だとすると、ユーザーのリクエストはいくつかのミドルウェア層を通過し、あなたのアプリケーションに到達し、さらにミドルウェア層を通過することがわかります。どのミドルウェアも、アプリケーションロジックの前、後、またはその両方で動作します。
つまり、ミドルウェアとは、アプリケーションの周りの一連のラッパーであり、アプリケーションロジックの一部ではない方法でリクエストとレスポンスを装飾する。
これは、ミドルウェアがデコレーターパターンを実装しているということである。ミドルウェアはリクエストを受け取り、何かを行い、スタックの次のレイヤーに別のリクエストオブジェクトを返す。
Laravelはデフォルトでミドルウェアを使用して、Cookieの暗号化/復号化、キューイング、セッションの読み書きを処理しますが、レート制限、カスタムリクエストパースなど、リクエスト/レスポンスサイクルに好きなレイヤーを追加することもできます。
上の図だとミドルウェアはルーティングの前って言っているけど、厳密に言うとルーティングの後って感じなんよな。
認証は「クライアントが誰であるかを特定すること」。認可は「クライアントに権限があるかを特定すること
例えばadminユーザーしかログインできてない機能とかある場合は、認証したユーザーがadminのアクセス権限を持つかを特定するために、認可が必要
Goのインターフェースはメソッドのみまとめられる
ブラウザはDELETEリクエストを出せる。しかし、フォームはGETリクエストとPOSTリクエストしか出せない。aタグはGETリクエストしか対応していない
scriptタグを使えばhtml内にJSをかけるscriptタグでsrc属性を使えば外部のJSファイルをインポートできる
ログアウト後にリダイレクトしないと、/logoutのパスがURLバーに表示されつつボディにマイページが入っているHTTPメッセージが来るから、DELETE /logoutをするなら、リダイレクトも必要やな
Wdy, DD Mon YYYY HH:MM:SS GMTのフォーマットでCookieのExpiresを指定しないと、Expiresが正しく解釈されない。
Expiresを正しく指定しないと、ExpiresがSessionとして解釈されてしまう(つまりセッションクッキーになっちゃう)。
セッションクッキーとはブラウザを閉じたら消えてしまうクッキーのことである。
ログイン時のSet-Cookieの値
session_id=ee5182d8-32db-429b-8df9-fe7fe68bb443; Expires=Sat, 07 Sep 2024 15:07:57 GMT; HttpOnly
同じ名前でExpiresが過去日のクッキーをSet-Cookieでサーバーサイドから送信したら、クライアントはそのレスポンスを受け取った瞬間に指定した名前のクッキーを消した。
ログアウト時にくっきー自体は消せた(Expires過去日にしたらいけた)。しかし、リダイレクト後にDELETEリクエストでログインページをリクエストするのはなんでやねん。
session_id=deactivated-value; Expires=Wed, 06 Sep 2023 15:08:49 GMT; HttpOnly
これはログアウトした際にステータスコードを200にした方が良いかも
出来上がったもの
ここまでで参考にした記事
location.hrefは同一ドメイン内であればパスを指定することで、そのドメインに属するページに遷移することができる