セッション管理の実装(Sessions テーブルと Redis)
プリザンターのセッションデータは、ASP.NET Core のセッションではなく独自の Sessions テーブル(既定)か Redis(Session.json の UseKeyValueStore: true)に保存されます。読み書きはどちらも SessionUtilities に集まっていますが、Redis 側には RDB と同じにならない処理がいくつかあります。
- セッション GUID は、データ保護 API で暗号化した Cookie
Pleasanter_SessionGuidに入ります。ログインのたびに振り直されます。 - 古いセッションの削除は、Web リクエストの処理中に 1 日 1 回だけ走ります。
@で始まる SessionGuid(ユーザー単位の値など)は削除されません。 - Redis を使う設定でも、
userAreaの値(Sessions API の値)は Sessions テーブルに書かれます。一方で読み込みは Redis から行うため、両者が食い違います。 - Sessions テーブルへの書き込みは「UPDATE して 0 行なら INSERT」で、同じキーへの同時書き込みで PRIMARY KEY 違反になることがあります。
対象バージョン
調査は 1.5.0.0 系のソースで行い、このページの内容は 1.5.8.1 のソース(コミット fdcbb3f)で確かめ直しています。セッション GUID の保存先など、1.5.8.1 で変わっている点はソースに合わせて書いています。
全体像
図を読み込み中…
| クラス | 役割 |
|---|---|
SessionUtilities | 読み書き・削除・古いセッションの削除・Sessions API の本体(SessionUtilities.cs) |
Context | セッション GUID の決定、リクエストごとのセッションデータの読み込み(Context.cs) |
AuthenticationTicketStore | Cookie 認証のチケットをセッションデータに置く ITicketStore(AuthenticationTicketStore.cs) |
SessionExclusive / TableExclusive | セッションデータを使った排他ロック(SessionExclusive.cs) |
CacheForRedisConnection | Redis の ConnectionMultiplexer を遅延生成して使い回す(CacheForRedisConnection.cs) |
セッション GUID の決まり方
Context.SessionGuid の初期値は Strings.NewGuid()(ハイフンなしの大文字 32 文字)です(Context.cs#L55)。SetSessionGuid() は Cookie Pleasanter_SessionGuid をデータ保護 API で復号し、読めればその値を使い、無ければ初期値を暗号化して Cookie に書きます(Context.cs#L331-L380)。復号に失敗した Cookie は削除されます。Cookie は HttpOnly、SameSite=Lax で、Security.json の SecureCookies が true か HTTPS のときに Secure が付きます(Context.cs#L382-L394)。
- Cookie が無いリクエストでは、
SessionMiddlewareがStartTimeとLastAccessTimeを書きます(Startup.cs#L897-L927)。 - ログイン(
FormsAuthenticationSignIn)と Windows 認証のときはRotateSessionGuid()で新しい GUID に振り直し、古い GUID の行を新しい GUID に付け替えます。Redis を使う設定では Redis のキーもKeyRenameで付け替え、TTL を付け直します(Context.cs#L1445-L1521)。 - ログアウトと
SessionAbandon()では Cookie を削除し、Redis を使う設定なら Redis のキーを削除します(Context.cs#L1339-L1364)。SessionUtilities.Abandon()はさらに Sessions テーブルの行を消します(SessionUtilities.cs#L262-L278)。
Startup.cs では ASP.NET Core のセッション(AddDistributedMemoryCache() と AddSession())も構成されていますが(Startup.cs#L128-L135)、1.5.8.1 では HttpContext.Session に値を書く Context.SetSessionData() の呼び出し元がありません。セッション GUID もセッションデータも、ASP.NET Core のセッションには置かれていません。
特別な SessionGuid
Sessions テーブルには、ブラウザのセッション以外の SessionGuid の行もあります。
| SessionGuid | 使う処理 | 内容 |
|---|---|---|
| Cookie の GUID | 画面・API のリクエスト | 通常のセッションデータ |
@{ユーザー ID} | Sessions API の SavePerUser: true(SessionUtilities.cs#L365)、ビューの保存方法がユーザー単位のサイト(Views.cs#L203-L222) | ユーザー単位の値 |
| 認証チケットごとの GUID | AuthenticationTicketStore.StoreAsync() | キー AuthenticationTicket に Base64 のチケット |
SessionExclusive | SessionExclusive(SessionExclusive.cs#L31) | サイト単位のロック情報 |
@AspNetCoreDataProtectionKeys | 既定のデータ保護キーの保存先(AspNetCoreKeyManagementXmlRepository.cs#L14) | ASP.NET Core のデータ保護キー(XML) |
Sessions テーブルの構造
| 列 | 型 | 主キー | 内容 |
|---|---|---|---|
SessionGuid | nvarchar(32) | 1 | セッション識別子 |
Key | nvarchar(256) | 2 | キー |
Page | nvarchar(32) | 3 | ページ単位の値のときの context.Page。それ以外は空文字 |
Value | nvarchar(max) | 値(文字列。オブジェクトは JSON にして保存) | |
ReadOnce | bit | 1 回読んだら消す値 | |
UserArea | bit | Sessions API などユーザー領域の値 |
このほかに全テーブル共通の Ver、Comments、Creator、Updator、CreatedTime、UpdatedTime があります(Sessions_SessionGuid.json、Sessions_Key.json、Sessions_Page.json)。テーブル定義上は Sessions_deleted と Sessions_history も作られますが、使われていません(データベースのテーブル構成)。
Page に入る context.Page は、items なら items/{サイト ID}(ゴミ箱では末尾に /trashbox)、users なら users/{ID} などです。多くの値は page: false で書かれ、Page は空文字になります。
主なキーは次のとおりです。
| キー | 書く処理 |
|---|---|
StartTime / LastAccessTime | セッション開始時と、ログを取るリクエストのたび(SysLogModel が SessionRequestInterval() を呼ぶ。SysLogModel.cs#L3432、Context.cs#L716-L720) |
AuthenticationTicket | ログイン時・チケット更新時 |
Message | 次の画面に出すメッセージ(readOnce: true。SessionUtilities.cs#L236-L243) |
View、View_{テーブル名} | 一覧のビュー(page: true) |
TableExclusive_SiteId={サイト ID} | サイト単位の排他ロック(SessionExclusive.cs#L90-L102) |
TempFile_{GUID} | 一時ファイルのダウンロード許可(BinaryUtilities.cs#L700-L739) |
SwitchLoginId | 特権ユーザーのユーザー切り替え |
User_{任意のキー} | Sessions API(SetUserArea() が User_ を付ける。SessionUtilities.cs#L283-L292) |
読み込み
リクエストごとに Context が作られ、次の 2 つを読み込みます。
| プロパティ | 読む SessionGuid | 呼び出し箇所 |
|---|---|---|
SessionData | Cookie の GUID | SetData()(Context.cs#L629-L640) |
UserSessionData | @{ユーザー ID} | SetUserProperties()(Context.cs#L514-L517) |
どちらも includeUserArea: Controller == "sessions" で読むため、UserArea の値が入るのは Sessions API のリクエストのときだけです。
SessionUtilities.Get() の RDB 側は、SELECT と、ReadOnce の行を消す DELETE を 1 回の呼び出しでまとめて送ります(SessionUtilities.cs#L57-L86)。
Pageが空文字の行と、Pageが今のcontext.Pageと一致する行を読むReadOnceの削除はcontext.ApiRequestBody == nullのときだけ。API のリクエストでは消さない
Redis 側は HashGetAll() でハッシュ全体を取り、{GUID}_readOnce のキーがあればそれも読んでキーごと削除します(SessionUtilities.cs#L35-L56)。Redis 側には UserArea の絞り込みがありません。
書き込み
SessionUtilities.Set() は context.SessionData を書き換えてから SetRds() で保存先に書きます(SessionUtilities.cs#L92-L192)。値が null なら Remove() になります。
図を読み込み中…
RDB と Redis の違い
| 項目 | RDB(Sessions テーブル) | Redis |
|---|---|---|
| 1 セッションの格納 | 行(主キー SessionGuid + Key + Page) | キー {GUID} のハッシュ |
| ページ単位の値 | Page 列 | フィールド名 {キー}_{ページ} |
ReadOnce の値 | ReadOnce 列。読み込み時に行単位で削除 | 別キー {GUID}_readOnce。読み込み時にキーごと削除 |
userArea の値 | 保存する | 保存しない(RDB に書く) |
個別キーの削除(Remove()) | 行を削除 | 何もしない(RDB だけ消す) |
| 期限切れ | 1 日 1 回の DeleteOldSessions() | 書き込みのたびに付ける TTL |
Redis ではキーの _ で分割される
Redis 側はフィールド名を {キー} または {キー}_{ページ} で書き(SessionUtilities.cs#L159)、読み込みでは Split('_') の 0 番目をキー、1 番目をページとして扱います(SessionUtilities.cs#L39-L44)。そのため、キー自体に _ を含む値は次のようになります。
- 2 番目の要素が今のページと一致しない値は読み込まれない(
context.Pageと引数のsessionGuidがどちらもnullのときは全部読まれる) - 読み込まれた値のキーは最初の
_の前だけになる(TempFile_ABC…はTempFileになる) - 前半が同じキーが複数読まれると、
ToDictionary()のキーが重複する
1.5.8.1 には View_{テーブル名}、TableExclusive_SiteId={サイト ID}、TempFile_{GUID} のように _ を含むキーがあります。たとえば一時ファイルのダウンロード許可は、SessionUtilities.Get() の結果に TempFile_{GUID} と完全一致するキーがあるかで判定しているため(BinaryUtilities.cs#L708-L716)、Redis を使う設定ではこの判定が一致しません。Sessions API の User_ の値は RDB に書かれるので、この分割の影響は受けません。
Remove() は Sessions テーブルだけを消す
SessionUtilities.Remove() は Sessions テーブルへの DELETE だけで、Redis のフィールドは消しません(SessionUtilities.cs#L248-L257)。呼び出し元ごとの扱いは次のとおりです。
| 呼び出し元 | 消すもの | Redis 側 |
|---|---|---|
AuthenticationTicketStore.RemoveAsync() | 認証チケット | CacheForRedisConnection.Clear() でキーごと削除(AuthenticationTicketStore.cs#L65-L77) |
| ユーザー切り替えの解除 | SwitchLoginId | HashDelete で個別に削除(UserUtilities.cs#L5278-L5290) |
| ユーザー切り替えの失敗時 | SwitchLoginId | 消さない(UserUtilities.cs#L5252-L5258) |
SessionExclusive.Clear() | ロック情報 | 消さない(SessionExclusive.cs#L73-L82) |
| 一時ファイル | TempFile_{GUID} | 消さない |
BinariesController のアップロード | アップロード中の情報 | 消さない |
Redis に残った値は TTL が切れるまで残ります。SessionExclusive のロックは取得時に更新時刻から 120 秒経っていれば取り直せるので(SessionExclusive.cs#L43-L59)、残ったロックは解放されたことにならず、120 秒経つまで他の処理が取得できません。
Redis を使うと Sessions API の値が読めない
userArea: true の値は、UseKeyValueStore が true でも Sessions テーブルに書かれます(SessionUtilities.cs#L155)。Sessions API の Set はこの userArea: true で書きます(SessionUtilities.cs#L354-L388)。
一方、Sessions API の Get と Delete が見るのは、Context が読み込んだ SessionData / UserSessionData です(SessionUtilities.cs#L297-L303、L320-L349)。これは SessionUtilities.Get() の結果で、UseKeyValueStore が true なら Redis から読まれます。Redis には Sessions API の値が無いため、Get と Delete は値を見つけられず 404 を返します。
データ保護キーの保存先
Security.json の AspNetCoreDataProtection で Blob も Redis も指定しない既定の構成では、データ保護キーは AspNetCoreKeyManagementXmlRepository が SessionUtilities 経由で SessionGuid @AspNetCoreDataProtectionKeys に保存します(Startup.cs#L273-L311、AspNetCoreKeyManagementXmlRepository.cs#L16-L67)。userArea ではないので、UseKeyValueStore が true なら Redis に書かれ、RetentionPeriod 分の TTL が付きます。Sessions テーブルに書かれる場合、この行は @ で始まるため DeleteOldSessions() の対象外で、キーの expirationDate から 90 日を過ぎたものが新しいキーを保存するときに消されます。
有効期限と古いセッションの削除
Session.json の RetentionPeriod(分、既定 1440)は次の 3 か所で使われます。
| 使われ方 | 箇所 |
|---|---|
ASP.NET Core のセッションの IdleTimeout | Startup.cs#L130-L132 |
認証 Cookie の ExpireTimeSpan(SAML の有無どちらも) | Startup.cs#L157、L174 |
| Redis のキーの TTL(書き込みのたび) | SessionUtilities.cs#L165 |
| Sessions テーブルの古い行の削除 | SessionUtilities.cs#L434-L450 |
Redis の TTL は読み込みでは延びず、書き込みのたびに付け直されます。ログを取るリクエストでは LastAccessTime を書くので、そのたびに TTL も付け直されます。
DeleteOldSessions()
if (前回実行日 != 今日) // SiteInfo.SessionCleanedUpDate(static)
{
SiteInfo.SessionCleanedUpDate = DateTime.Now;
DELETE FROM Sessions
WHERE UpdatedTime < 現在 - RetentionPeriod 分
AND SessionGuid NOT LIKE '@%';
}- 前回実行日は
static変数なので、プロセス(Web サーバー)ごとに 1 日 1 回です。日付はリクエストしたユーザーのタイムゾーンで比べます。プロセスの起動直後の最初の呼び出しでは必ず実行されます。 - 削除は行単位で、行ごとの
UpdatedTimeを見ます。セッション全体ではなく、長く書かれていないキーの行から消えます。 @で始まる SessionGuid(Sessions API のSavePerUser: true、ユーザー単位のビュー、データ保護キー)は消しません。これらは明示的に消すまで残ります。UseKeyValueStoreがtrueでも、この削除は Sessions テーブルに対して実行されます。
呼び出し元は Context.SetData() だけです(Context.cs#L640)。SetData() は、sessionData(既定 true)と user(既定 true)が有効な Context で、ルート情報があるリクエストのときだけ呼ばれます(Context.cs#L227-L243、L481-L485)。
図を読み込み中…
バックグラウンド処理や認証チケットの読み書きは sessionData: false や Context(tenantId: ...) で Context を作るので、削除は走りません。利用者のアクセスが無い時間帯には、古い行はそのまま残ります。
同時書き込みと PRIMARY KEY 違反
Sessions テーブルへの書き込み(UpdateOrInsertSessions)は、DB ごとに次の SQL になります。
| DB | 生成される SQL | 箇所 |
|---|---|---|
| SQL Server | update ... の後に if @@rowcount = 0 insert ... | SqlServerCommandText.cs#L60-L79 |
| PostgreSQL | with CTE1 as (update ... returning 0) insert ... where not exists(select * from CTE1) | PostgreSqlCommandText.cs#L64-L91 |
| MySQL | update ...; と insert ... where not exists (select 1 from ...) の 2 文 | MySqlCommandText.cs#L71-L90 |
SetRds() はトランザクションを指定せずに実行します(SessionUtilities.cs#L169-L182)。どの DB でも、行がまだ無いときの UPDATE は行ロックを取らないので、同じ SessionGuid + Key + Page に 2 つのリクエストが同時に書くと、両方が INSERT に進み、後の方が主キー違反になります。
図を読み込み中…
SQL Server では「PRIMARY KEY 違反。オブジェクト 'dbo.Sessions' には重複するキーを挿入できません。」というエラーになります。起きやすいのは、同じセッションの行がまだ無い(またはクリーンアップで消えた)状態で、画面表示と非同期のリクエストがほぼ同時に来たときです。失敗した書き込みの値は保存されませんが、LastAccessTime のように次のリクエストでまた書かれる値は、次の書き込みで保存されます。
Redis の HashSet は 1 コマンドで上書きするため、Redis に書く値ではこの違反は起きません。ただし userArea の値は Redis を使う設定でも Sessions テーブルに書かれます。
同じ「UPDATE → INSERT」の形は Upsert API にもあります(Upsert 処理)。
複数台構成で共有が必要なもの
1.5.8.1 ではセッション GUID を暗号化した Cookie に持つので、どの Web サーバーがリクエストを受けても、次の 2 つが共有されていれば同じセッションとして扱われます。
| 共有するもの | 既定の置き場所 | 別の置き場所 |
|---|---|---|
| セッションデータ | Sessions テーブル(DB なので共有される) | UseKeyValueStore: true で Redis |
| データ保護キー(Cookie の復号に使う) | Sessions テーブルの @AspNetCoreDataProtectionKeys | Security.json の AspNetCoreDataProtection で Azure Blob Storage か Redis |
ASP.NET Core のセッション(AddDistributedMemoryCache())はプロセス内のメモリですが、1.5.8.1 ではセッション GUID もセッションデータもそこに置かれていないため、サーバー間で共有されなくても影響しません。