はじめに
遅ればせながらこの記事の続編を書いてみる。
Rust WebフレームワークのACTIXで使う安全なセッション管理機構を考えてみる
CookieAuthよりはマシなセッション管理をしてみる話。
作るもの
actix-redisとCookieAuthを組み合わせて、無操作状態では1分後くらいにセッションが切れるようなWebアプリ(ブラウザごとに異なるカウンターを持つアプリ)
を作っていこうと思います。
ログイン(認証・認可)については色々と面倒なので省きますが
ぶっちゃけ発展させればオッケーなのでここではやらないです。
実装方針
actix-redis:セッションのタイムアウトや、セッションIDの生成を行う
ただしこれだけでは、過去発行したIDはセッションが無効化(DBから削除)されるまでは自分でCookieにつけてしまえば使えてしまうのでSessionFixation攻撃(*1)にも脆弱となる。
そこで、PHPにあるような session_regenerated_id関数みたいな再生成をするかどうかになるが、「そんなものはない」ので
認証済みかそうでないかを判定するセキュリティ的な観点で強度の高いトークン(*2)をログインするごとに付け替える。
2019/10月追記
また、ログイン後のページ遷移時は上記で取得した期限内のトークン2つを送付しそれらがすべて正しいことを確認したうえで新たなトークンを返却し、処理を行うことが望ましい。
さらに、コミット時には、上記2トークンとCSRFトークンの3つの検証を行う必要がある。
(*1)SessionFixation攻撃:攻撃者が正規サイトで発行したセッションIDを何らかの手段で被害者に強制してログインさせることで、攻撃者は被害者のセッションで色々できてしまう。今回の場合は、カウンタを1進めるという事ができてしまう。
(*2)セキュリティ的な観点で強度の高いトークン:ここではCSPRNGで生成した256bit以上の乱数列と定義する。まぁ、これくらいあれば大丈夫でしょう。
実装
部分部分で紹介していって最後に、全体を載せているのでのんびり読んでくださいな。(時間がなければ飛ばして最後へ)
部分の紹介時は、use部分は割愛しています。
はい、いきなり前提を覆しますが、とりあえず色々と面倒なのでCSPRNGは使用せず普通のPRNGを使用していきたいと思いまーす。(コンセプトコードの位置づけ)
というわけで、randクレートを使って乱数生成をします。(削除理由は後述)
プログラム全体の設定・ルーティングの設定・サーバを起動をするコード
fn main() {
let sys = actix::System::new("http2_api-test");
let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
builder.set_private_key_file("key.pem", SslFiletype::PEM).unwrap();
builder.set_certificate_chain_file("cert.pem").unwrap();
server::new(|| {
App::new()
.middleware(SessionStorage::new(
// ここのコンストラクタの初期化値もちゃんと知りえないものに変更しましょう。
RedisSessionBackend::new("127.0.0.1:6379", &[0; 32])
.ttl(60) // 1分でセッションタイムオーバ
// secure属性をつける(httpのときにはCookieを送出しないようにする。HSTSヘッダも出せれば出すべき。)
.cookie_secure(true)
))
.middleware(IdentityService::new(
// ここのコンストラクタの初期化値もちゃんと知りえないものに変更しましょう。
CookieIdentityPolicy::new(&[0; 32])
.name(AUTH_TOKEN_NAME)
.secure(true) // secure属性をつける。以下同上
))
.resource("/", |r| r.method(http::Method::GET).f(|req| index(&req)))
.route("/login",http::Method::GET,login) // 簡単のためGETにしている。
.route("/logout",http::Method::GET,logout)
}).bind_ssl("127.0.0.1:8443", builder).unwrap().start();
println!("Started http server: 127.0.0.1:8443");
let _ = sys.run();
}
ログイン機能(のようなもの、ハリボテ)
ログイン完了した場合のみ、256bitの乱数を生成してセッション変数に保存&Auth-tokenとしてそのままCookieの値に入れて送信。
ログイン後に表示される画面では、常にこの値を検証してログイン状態かどうかを判定する。
ログイン判定用メソッドを作ると便利かもしれない。
乱数生成ではrandクレートのOsRngを使います。
この生成器はWindowsではRtlGenRandom・Linuxではgetrandom(2)を内部的に使用するという仕様でした。一応これらの関数たちはCSPRNGの位置づけ。
static AUTH_TOKEN_NAME:&str="X-auth-token";
fn login(req: HttpRequest) -> Result<HttpResponse> {
// このあたりにログイン処理を書く
// IDとパスワードが一致してログイン完了(したとして)
// 乱数(本来はCSPRNGを使用するべき)を生成する。
let mut r = OsRng::new().unwrap();
let mut auth_token=vec![0u8; 256/8]; // 256bit
r.fill_bytes(&mut auth_token);
let auth_token = encode(&auth_token);
req.remember(auth_token.clone());
let token = req.session().set::<String>(AUTH_TOKEN_NAME,auth_token.clone())?;
req.session().set("counter",0);
Ok(HttpResponse::Found().header("location", "/").finish())
}
ログイン状態と非ログイン状態が混在しているいわゆる「トップページ」的な画面を作るメソッド。
fn index(req: &HttpRequest) -> Result<HttpResponse> {
// まだ「ログイン」をしていない状態だった。
let not_login = Ok(HttpResponse::Ok()
.content_encoding(ContentEncoding::Br)
.body("Welcome Anonymous!".to_owned()));
let auth_token = req.session().get::<String>(AUTH_TOKEN_NAME)?;
let recv_token = req.identity();
// どちらかのCookie値が空の場合は、ログインをしていない。
if auth_token==None || recv_token==None{
return not_login;
}
// セッション変数に格納した乱数値とログイン完了時に発行した
// 乱数値が一致しなければ不正なものとしてログインしていない状態として扱う。
if auth_token.unwrap() == recv_token.unwrap(){
let counter = req.session().get::<i64>("counter")?.unwrap();
req.session().set("counter",counter+1);
Ok(HttpResponse::Ok()
.content_encoding(ContentEncoding::Br)
.body(format!("counter {:?}", counter))
)
}else{
// ユーザが飛ばしてきた乱数値と内部で管理している乱数値が一致しなかった
not_login
}
}
ログアウト機能
fn logout(req: HttpRequest) -> HttpResponse {
// 認証トークンをクライアントのブラウザから削除する
// 削除しなくても良い。無効な値として使われるので。あくまでもおせっかい。
req.forget();
// セッションから認証済みトークンなどの情報を全削除
// これは必ず明示的なログアウト時にはやらなければならない。
// 暗黙的なログアウトはタイムアウトが起きればされるが、
// 「放置してれば切れるからいいや」というのは
// ユーザの意に反するので設計としてあまり良くない。
req.session().clear();
HttpResponse::Found().header("location", "/").finish()
}
プログラムの全体像
use actix_redis::RedisSessionBackend;
use rand::{RngCore};
use rand::rngs::{OsRng};
use base64::{encode, decode};
use actix_web::*;
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod};
use actix_web::middleware::identity::{CookieIdentityPolicy, IdentityService,RequestIdentity};
use actix_web::middleware::session::{SessionStorage,RequestSession};
use actix_web::{HttpRequest, HttpResponse, http::ContentEncoding};
fn index(req: &HttpRequest) -> Result<HttpResponse> {
// まだ「ログイン」をしていない状態だった。
let not_login = Ok(HttpResponse::Ok()
.content_encoding(ContentEncoding::Br)
.body("Welcome Anonymous!".to_owned()));
let auth_token = req.session().get::<String>(AUTH_TOKEN_NAME)?;
let recv_token = req.identity();
// どちらかのCookie値が空の場合は、ログインをしていない。
if auth_token==None || recv_token==None{
return not_login;
}
// セッション変数に格納した乱数値とログイン完了時に発行した
// 乱数値が一致しなければ不正なものとしてログインしていない状態として扱う。
if auth_token.unwrap() == recv_token.unwrap(){
let counter = req.session().get::<i64>("counter")?.unwrap();
req.session().set("counter",counter+1);
Ok(HttpResponse::Ok()
.content_encoding(ContentEncoding::Br)
.body(format!("counter {:?}", counter))
)
}else{
not_login
}
}
static AUTH_TOKEN_NAME:&str="X-auth-token";
fn login(req: HttpRequest) -> Result<HttpResponse> {
// このあたりにログイン処理を書く
// IDとパスワードが一致してログイン完了(したとして)
// 乱数(本来はCSPRNGを使用するべき)を生成する。
let mut r = OsRng::new().unwrap();
let mut auth_token=vec![0u8; 256/8]; // 256bit
r.fill_bytes(&mut auth_token);
let auth_token = encode(&auth_token);
req.remember(auth_token.clone());
let token = req.session().set::<String>(AUTH_TOKEN_NAME,auth_token.clone())?;
req.session().set("counter",0);
Ok(HttpResponse::Found().header("location", "/").finish())
}
fn logout(req: HttpRequest) -> HttpResponse {
// 認証トークンをクライアントのブラウザから削除する
// 削除しなくても良い。無効な値として使われるので。あくまでもおせっかい。
req.forget();
// セッションから認証済みトークンなどの情報を全削除
// これは必ず明示的なログアウト時にはやらなければならない。
// 暗黙的なログアウトはタイムアウトが起きればされるが、
// 「放置してれば切れるからいいや」というのは
// ユーザの意に反するので設計としてあまり良くない。
req.session().clear();
HttpResponse::Found().header("location", "/").finish()
}
fn main() {
let sys = actix::System::new("http2_api-test");
let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
builder.set_private_key_file("key.pem", SslFiletype::PEM).unwrap();
builder.set_certificate_chain_file("cert.pem").unwrap();
server::new(|| {
App::new()
.middleware(SessionStorage::new(
RedisSessionBackend::new("127.0.0.1:6379", &[0; 32])
.ttl(60) // 1分でセッションタイムオーバ
// secure属性をつける(httpのときにはCookieを送出しないようにする。HSTSヘッダも出せれば出すべき。)
.cookie_secure(true)
))
.middleware(IdentityService::new(
CookieIdentityPolicy::new(&[0; 32])
.name(AUTH_TOKEN_NAME)
.secure(true) // secure属性をつける。以下同上
))
.resource("/", |r| r.method(http::Method::GET).f(|req| index(&req)))
.route("/login",http::Method::GET,login) // 簡単のためGETにしている。
.route("/logout",http::Method::GET,logout)
}).bind_ssl("127.0.0.1:8443", builder).unwrap().start();
println!("Started http server: 127.0.0.1:8443");
let _ = sys.run();
}
これで、60秒無操作(書き込み操作がない状態)であれば自動的にログアウト(セッションタイムアウト)の状態が作れる。
また、再ログイン時に同じセッションIDを持っていても、認証トークンが違うものになるので
どこからかセッションIDが漏れたとしても攻撃者はログイン状態を再現することができなそう
と、セッション固定化攻撃に対してある程度強固になったかなと現時点では考えている。
おわり。