マルチユーザーWebアプリケーションの開発中に、複数のログイン(古いセッションが終了していない新しいログイン)の問題に遭遇しました。その解決には、プログラムの論理操作とその明確な設計を維持するための異常な回避策が必要でした。 この記事では、Spring Securityのセッション管理に対する従来のアプローチを最初に説明し、私たちのデザインの「松葉杖」の形で合理的な提案でレビューを完了することで、私の経験を共有したいと思います。
セッション制御の問題は、多くのプロジェクトに関連しています。 私の場合、それはゲーム(Java + Springバックエンド)であり、登録ユーザーはサイトに存在する無料プレイヤーのリストから誰と戦うかを選択できます。 プレイヤーのログイン後、プレイヤーに関する情報がメモリ内のデータ構造に追加されます。 このデータの一部は、アリーナにいるプレーヤーのリストとして、ゲームインターフェースに非同期的に表示されます。 プレイヤーが退出すると、そのプレイヤーに関する情報をデータベースに保存し、データ構造から削除する必要があり、プレイヤーはオンラインの対戦相手のリストに表示されなくなります。 ここでは非同期のためにいくつかの問題が発生しましたが、記事のトピックから離れているため、それらには触れません。
ログインとログアウトに関連するさまざまな状況の管理戦略について詳しく説明します。 まず、プレイヤーが自分の行動の結果としてアリーナを離れることができるという事実を考慮する必要がありました。
- 彼は誠実にログアウトできます(ログアウトボタンをクリックして)。
- ブラウザー、ラップトップカバー、プレスリセットなどを閉じるだけで、通常は英語のままにします。
英語で出発します
このような「英語」シナリオでは、次のアプローチが使用されます。
1. Spring MVCアプリケーションの標準的な初期化および構成中に、DispatcherServletの登録中にSessionEventListenerが追加されます。
public class MyApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { // ... // @Override protected void registerDispatcherServlet(ServletContext servletContext) { super.registerDispatcherServlet(servletContext); servletContext.addListener(new SessionEventListener()); } }
2.セッションイベントのリスナーが実装されます。
public class SessionEventListener extends HttpSessionEventPublisher { // ... @Override public void sessionCreated(HttpSessionEvent event) { super.sessionCreated(event); // ... // event.getSession().setMaxInactiveInterval(60*10); } @Override public void sessionDestroyed(HttpSessionEvent event) { String name=null; //---- login SessionRegistry SessionRegistry sessionRegistry = getAnyBean(event, "sessionRegistry"); SessionInformation sessionInfo = (sessionRegistry != null ? sessionRegistry .getSessionInformation(event.getSession().getId()) : null); UserDetails ud = null; if (sessionInfo != null) ud = (UserDetails) sessionInfo.getPrincipal(); if (ud != null) { name=ud.getUsername(); // , getAnyBean(event, "allGames").removeByName(name); } super.sessionDestroyed(event); } // public AllGames getAnyBean(HttpSessionEvent event, String name){ HttpSession session = event.getSession(); ApplicationContext ctx = WebApplicationContextUtils. getWebApplicationContext(session.getServletContext()); return (AllGames) ctx.getBean(name); } }
3. SessionRegistryがSpring Security構成に追加されます。
@Configuration @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter { //... @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/login") .failureHandler(new SecurityErrorHandler()) //... .and() .sessionManagement() .invalidSessionUrl("/home") .maximumSessions(1) .maxSessionsPreventsLogin(true) .sessionRegistry(sessionRegistry()); } // Spring SessionRegistry @Bean(name = "sessionRegistry") public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } }
現在、(SessionEventListenerで)新しいセッションごとに 'event.getSession()。SetMaxInactiveInterval(60 * 10)'のタイムアウトを設定しているため、英語の終了スクリプトは短時間で終了します(この例では-10分)セッションの有効期限が切れます。 sessionDestroyedイベントはすぐにスローされ、リスナーによって処理されます。リスナーは適切なサービスを呼び出して、プレーヤーをアリーナから削除し、永続データを保存し、キャッシュをクリアします。 それが私たちが望んでいたことです。 このすべてのロジックをsessionDestroyed処理から呼び出される単一のメソッドに配置することにより、設計を大幅に簡素化します。
ログイン-選択の自由
これまで、Spring Securityは必要な柔軟性を実証してきました。 ただし、ここでは、承認中にさまざまなユーザー動作オプションを同じ方法で考慮することが望まれました。 そのため、プレーヤーは次のことができます。
- 開いているセッションがないときにクリーンなログインを作成します。
- ログアウトボタンを押して古いセッションを終了したくない(ブラウザウィンドウを閉じたり、ラップトップカバーを閉じたりするなど)ことがあり、10分のタイムアウトが経過するまでセッションは開いたままになります。 プレイヤーは、携帯電話、タブレット、その他のコンピューターのオプションとして、別のより便利なブラウザーからの入力を切望しています。
さらに、プレーヤーの動作の最後のバリエーションは、意図的なもの(デバイスの変更)または単純なミス(注意散漫)のいずれかです。
この場合、標準のSpring Securityアプローチは何を提供しますか。 構成中に次のプロパティを設定します。
@Override protected void configure(HttpSecurity http) throws Exception { http //... .and() .maximumSessions(1) .maxSessionsPreventsLogin(false); //
この構成では、プレーヤーは.maximumSessions(1)と同時に複数のセッションを開くことができず、2番目のセッションを開こうとすると、最初のセッションはすぐに強制終了されます.maxSessionsPreventsLogin(false)そして古いセッションのブラウザーウィンドウが開かれた場合、ユーザー彼は、設定 '.invalidSessionUrl( "/ home")'のおかげで、ゲームが指定されたページにスピンしていたページ[ * ]からどのように遷移が自動的に発生するかを見ることができます。
疲れませんでした。 この行動以来、Spring Securityは予防的な核爆撃のようなものでした。 プレイヤーは誤って再度ログインする可能性があり、警告なしの最後のゲームは終了します。 次のいずれかのオプションを選択できる警告ウィンドウがプレーヤーに表示されるように、このシナリオを改良する必要がありました。
- 停止し、考えを変えて、再度ログインせずに、すでに開いているゲームに戻ります。
- 再度ログインして、最後のセッションを強制終了します(これは、プレーヤーが最後の、まだアクティブなセッションでブラウザーウィンドウを閉じただけでも、データを保存するなどして正しく行われるはずです)
このため、次の設定が優先されました。
@Override protected void configure(HttpSecurity http) throws Exception { http //... .and() .maximumSessions(1) // .maxSessionsPreventsLogin(false) // .maxSessionsPreventsLogin(true);
現在、「。maxSessionsPreventsLogin(true)」を設定した結果、閉じられていない最後のセッションでプレーヤーを再ログインすると、Spring Securityでより具体的なSessionAuthenticationExceptionが発生します。 それを処理し、ユーザーをhtml警告ページにリダイレクトするだけで、さらに次の選択肢が設定されます。a)続行せず、最後の開いているセッション(ゲームが進行中の可能性がある)に戻ります。 b)まだログインしてから、最後のセッションを終了する必要があります。
このような例外のハンドラーは、Spring Securityの構成中に「.failureHandler(new SecurityErrorHandler())」として登録され、ハンドラークラス自体は次のように実装されます。
public class SecurityErrorHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { if (exception.getClass() .isAssignableFrom(SessionAuthenticationException.class)) { // warning-page, login URL // ( login ) request.getRequestDispatcher("/double_login_warning/"+ request.getParameterValues("username")[0]) .forward(request, response); //... } }
セッションヘッドを切り落としてみましょう
ユーザーがオプションを選択した場合、適切なアクションを実行するために残ります-再度ログインして最後のセッションを終了します。 Spring Securityにはこの機能があり、expireNow()メソッドによってSessionInformationクラスに実装されます。 この方法は、任意のユーザーのセッションを終了するために使用することが提案されています。 ユーザー名を使用して特定のユーザーのSessionInformationを見つけるために、次のサービスが作成されました。
@Service("expireUsereService") @Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS) public class SessionServise { // sessionRegistry private SessionRegistry sessionRegistry; @Autowired public void setSessionRegistry(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } // public void expireUserSessions(String username) { for (Object principal : sessionRegistry.getAllPrincipals()) { if (principal instanceof User) { UserDetails userDetails = (UserDetails) principal; if (userDetails.getUsername().equals(username)) { for (SessionInformation information : sessionRegistry .getAllSessions(userDetails, true)) { // information.expireNow(); } } } } } }
このアプローチはSpring Securityコミュニティで繰り返し説明されてきましたが、重大な欠点があります。 実装されると、直感的に予想されるアクションは発生しません。 セッションはもちろん期限切れと宣言されますが、閉じません。 言い換えると、推奨されるexpireNow()を手動で呼び出した後、セッションは破棄されません。 つまり:
- 前のブラウザのフロントエンド(意図的に拒否し、すべての結果で既に破棄されていることを期待するセッション)で、プレイヤーは進行中のゲームを見ます(javascriptが個別にアニメーションをスクロールする場合、幻想は非常に現実的です);
- sessionDestroyedイベントは発生せず、ユーザーデータは保存されず、ゲームアリーナは更新されませんでした。 これは、マルチユーザーシステムのロジックに大きく違反します。
追跡されたセッションが撮影されますか?
なぜこれが起こっていますか。 SessionInformationオブジェクトでexpireNow()メソッドを呼び出すと、そのフィールドの値がexpired = trueに設定されるだけです。 他のアクションは実行されず、実行されるべきではありません。 ユーザーが古いセッションから新しいHTTPリクエストを送信した場合にのみ、この期限切れのセッションは強制終了され、ユーザーはブラウザーでログインページへのリダイレクトがどのように発生したかを確認し、sessionDestroyedイベントを修復します(予想される動作)。 これは、a)サーブレットコンテナがセッションの破棄に関与しており、この場合、新しいHTTPリクエストを受信した後にこれを行うという事実によるものです。 b)フィルターチェーン(Javaサーブレットフィルター)を介して実装されたSpring Security機能は、リクエストを受信するまで何もしません。 c)サーブレットに追加したSessionEventListenerリスナーは、新しいHTTP要求が原因でsessionDestroyedイベントを処理します。
Springのドキュメントを含む多くの人が推奨するように、「expireNow()」セッション制御メソッドは、素朴な期待に反して機能します。 私たちの場合、これはアプリケーションの同期に違反しました。 Spring Securityセッションコントロールでは、最後のセッションがexpired = true(SessionAuthenticationExceptionがスローされなくなった)が宣言された後、これが許可されるため、「expireNow()」の後の再ログインが既に可能であることが重要です。 Springのドキュメントでは、これについて非常に表面的に説明しています。 同時に、前のセッションは実際には破棄されず、sessionDestroyedイベントは処理されませんでした。したがって、(おそらく再度ログインするために)ログアウトすることを期待しているプレーヤーに関する情報は保存されません。 ゲーム(チャットや他の対話型アプリケーションなど)は、古いセッションなどにメッセージを送信します。 プレーヤーが再度ログインすると、新しいセッションの競合的な作成と、ヘビー級のスレッドセーフツールで処理できるsessionDestroyedの開発に関連して混乱が発生します。 しかし、すべてをよりシンプルにすることができます。
この状況を修正し、再ログインと古いセッションの終了のロジックを予測しやすくするために、次のアプローチが使用されました。 SessionService(Beanの名前は「expireUsereService」)に次のメソッドを追加します。
public void killExpiredSessionForSure(String id) { // //id - SessionID, // getSessionId() SessionInformation try { HttpHeaders requestHeaders = new HttpHeaders(); requestHeaders.add("Cookie", "JSESSIONID=" + id); HttpEntity requestEntity = new HttpEntity(null, requestHeaders); RestTemplate rt = new RestTemplate(); rt.exchange("http://localhost:8080", HttpMethod.GET, requestEntity, String.class); } catch (Exception ex) {} // }
このメソッドを呼び出すことにより、セッションが古くなっているとフラグを立てたユーザーからのhttpリクエストをシミュレートします。 「expireNow()」の直後に「killExpiredSessionForSure(id)」を呼び出すと、望ましい動作が発生します。
- 古くなったセッションのある開いているブラウザウィンドウでは、ユーザーは(受動的に観察し、何も押さないで)ログイン/ホームページへの「美しい」 [ * ]強制移行をすぐに見ます。
- sessionDestroyedイベントがトリガーされ、プレーヤーとそのデータのアリーナを更新および保存するためのすべてのロジックがトリガーされます。 松葉杖はもう必要ありません。
当初、同僚と私は、追加のデータ構造にオープンセッションを保存する、別のストリームからのオープンセッションを監視するなどのアイデアを思いつきました。 しかし、私の意見では、廃止されたセッションに代わってhttpリクエストを単純に呼び出す(提案されたJSESSIONIDを置き換える)提案されたオプションはよりエレガントです。
要約する
一般に、これのおかげで、アプリケーションはより直感的に動作し始め、その設計のアイデアが実現しました。 オンラインユーザーに関するデータを更新し、何らかの方法でログアウトしたユーザーのデータを保存するすべてのコードをsessionDestroyedイベントハンドラーに配置するというアイデアは、堅牢なものであることが判明しました。 正しい実装のためには、期限切れのセッションを破棄するための追加メカニズムを作成するだけで済みました。これについては、この記事の結論で説明します。
さらに、このアプローチ、つまりメソッド呼び出しの組み合わせを使用する-よく知られている「expireNow()」と提案されている「killExpiredSessionForSure(String id)」は、そのような場合に使用できます。
- あなたが管理者であり、システムにログインしているユーザーのセッションを安全に破りたい場合。 その結果、ユーザーはシステムから「サージ」をすぐに見ることができ( [ * ]をホーム/ログインページに切り替えます)、データの更新を保存するためのすべてのロジックはsessionDestroyedハンドラーで実装できます。
- ユーザーがブラウザウィンドウを閉じた後、最短時間後にセッションが強制終了された場合に、一般的なシナリオを実装します。 この場合、バックエンドに信号を送信するアプリケーションのクライアント部分などで特別なハートビートを作成する必要がありますが、これは次の出版物のトピックかもしれません。
ご注意
*-移行はフロントエンドのコードによるものです。 この場合、ゲーム中の現在のメッセージはWebSocketを使用して送信されます。 WebSocketは、接続を確立するためにのみ(変更された)HTTPプロトコルを使用し、TCP上で実行されるWebSocketプロトコルを使用してメッセージを交換します。 したがって、これらのメッセージの交換は、一般にサーブレットフィルタ、特にSpring Securityフィルタチェーンによってフィルタリングされません。 したがって、期限が切れたセッションでも、改善前にゲームメッセージの交換がありました。 このようなメッセージを送信しても、期限切れのセッションは破棄されませんでした。 本来あるべきではないところでゲームを続けるという幻想がありました。 ただし、(killExpiredSessionForSure(id)を呼び出すことにより)セッションが永続的に破棄されると、WebSocket接続は自動的に切断されます。 フロントエンドコードはこれに気づき(WebSocket接続が切断されると、指定されたコールバックが実行されます)、ホーム/ログインページページに移動します。 SpringのStomp実装には、サーバー側からWebSocketセッションを切断するためのAPIがないため、このメソッドを使用すると、バックエンドとのWebSocket接続を中断できます。