Akka HTTP Session
ライブラリ
Akka HTTP Session
概要
Akka HTTP Sessionは、SoftwareMill社が開発したScala製のクライアントサイドセッション管理ライブラリです。2025年現在、Akka HTTPフレームワークにセッション機能を追加し、Webアプリケーションとモバイルアプリケーションの両方に対応しています。CookieまたはHTTPヘッダーベースのセッション管理、JWT統合、CSRF保護、サーバーサイド署名による改ざん防止機能を提供します。セッションデータは暗号化可能で、デフォルトで1週間の有効期限を持ちます。モバイルアプリ、SPA、Web APIなど多様なクライアント環境に対応し、Akka HTTPアプリケーションに軽量かつセキュアなセッション管理を提供する業界標準ライブラリです。
詳細
Akka HTTP Session 0.7系は、Akka HTTPの軽量性を維持しながら、エンタープライズレベルのセッション管理を実現します。クライアントサイドアプローチにより、サーバーサイドのセッションストレージが不要で、水平スケーリングが容易です。サーバーシークレットによる暗号学的署名でセッションデータの完全性を保証し、AES暗号化により機密性を提供します。JWT統合により、RFC 7519準拠のトークンベース認証をサポートし、iss(発行者)、sub(主体)、aud(対象者)などの標準クレームを設定可能です。ディレクティブベースの直感的なAPIにより、setSession、requiredSession、invalidateSessionなどの操作を簡単に実装できます。
主な特徴
- クライアントサイドセッション: サーバーサイドストレージ不要でスケーラブルな設計
- 複数トランスポート対応: Cookie(Web)とHTTPヘッダー(モバイル)の両方サポート
- JWT統合: RFC 7519準拠のJSON Web Token形式でのセッション符号化
- 暗号化とCSRF保護: AES暗号化とCSRFトークンによる包括的セキュリティ
- ディレクティブベースAPI: Akka HTTPの思想に沿った宣言的なセッション管理
- 設定可能な有効期限: デフォルト1週間、要件に応じてカスタマイズ可能
メリット・デメリット
メリット
- クライアントサイドアプローチによりサーバーリソース消費が少なく、水平スケーリングが容易
- Akka HTTPとのシームレス統合で、既存のディレクティブと自然に組み合わせ可能
- JWT対応により、マイクロサービス間でのトークン共有とステートレス認証を実現
- 暗号化とCSRF保護により、エンタープライズレベルのセキュリティ要件に対応
- CookieとヘッダーのDual Transport対応で、Web・モバイル両対応アプリに最適
- 軽量かつ高性能で、Akka HTTPの非同期処理性能を損なわない
デメリット
- セッションデータがクライアントに保存されるため、機密データの格納には制限がある
- Akka HTTP専用でPlay FrameworkやSpray等の他のScalaフレームワークでは利用不可
- JVMメモリ効率化のため、大容量セッションデータには向かない
- サーバーシークレット管理が重要で、漏洩時はすべてのセッションが無効化される
- モバイルアプリでは手動でのセッションストレージ実装が必要
- セッション無効化がサーバーサイドからリアルタイムで制御できない
参考ページ
書き方の例
基本的なセットアップとCookieベースセッション
// build.sbt - 依存関係追加
libraryDependencies ++= Seq(
"com.softwaremill.akka-http-session" %% "core" % "0.7.1",
"com.softwaremill.akka-http-session" %% "jwt" % "0.7.1", // JWT使用時
"com.typesafe.akka" %% "akka-http" % "10.5.0",
"com.typesafe.akka" %% "akka-stream" % "2.8.0"
)
// application.conf - 設定ファイル
akka.http.session {
server-secret = "your-secret-key-change-in-production"
max-age = 7 days
encrypt-data = true
cookie {
name = "app_session"
secure = true
http-only = true
same-site = strict
}
csrf {
cookie-name = "csrf_token"
submitted-name = "csrf_token"
}
}
// SessionService.scala - セッション管理サービス
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import com.softwaremill.session.SessionDirectives._
import com.softwaremill.session.SessionOptions._
import com.softwaremill.session.{SessionConfig, SessionManager}
case class UserSession(userId: String, username: String, roles: Set[String])
class SessionService(implicit sessionConfig: SessionConfig) {
implicit val sessionManager: SessionManager[UserSession] = new SessionManager[UserSession](sessionConfig)
// ログインルート
def loginRoute: Route =
path("login") {
post {
entity(as[LoginRequest]) { loginRequest =>
// 認証ロジック(実際の実装では外部認証システムを使用)
authenticate(loginRequest) match {
case Some(user) =>
val userSession = UserSession(user.id, user.username, user.roles)
setSession(oneOff, usingCookies, userSession) { ctx =>
complete("ログイン成功")
}
case None =>
complete((401, "認証失敗"))
}
}
}
}
// ログアウトルート
def logoutRoute: Route =
path("logout") {
post {
invalidateSession(oneOff, usingCookies) { ctx =>
complete("ログアウト完了")
}
}
}
// セッション必須ルート
def protectedRoute: Route =
path("protected") {
get {
requiredSession(oneOff, usingCookies) { session: UserSession =>
complete(s"こんにちは、${session.username}さん! あなたの権限: ${session.roles.mkString(", ")}")
}
}
}
// セッション情報取得ルート
def sessionInfoRoute: Route =
path("session") {
get {
optionalSession(oneOff, usingCookies) { sessionOption: Option[UserSession] =>
sessionOption match {
case Some(session) =>
complete(SessionInfo(session.userId, session.username, authenticated = true))
case None =>
complete(SessionInfo("", "", authenticated = false))
}
}
}
}
private def authenticate(loginRequest: LoginRequest): Option[User] = {
// 実際の認証ロジック(データベース、LDAP等)
if (loginRequest.username == "admin" && loginRequest.password == "password") {
Some(User("1", "admin", Set("admin", "user")))
} else None
}
}
case class LoginRequest(username: String, password: String)
case class User(id: String, username: String, roles: Set[String])
case class SessionInfo(userId: String, username: String, authenticated: Boolean)
HTTPヘッダーベースセッション(モバイルアプリ対応)
// HeaderSessionService.scala - ヘッダーベースセッション
import com.softwaremill.session.SessionDirectives._
import com.softwaremill.session.SessionOptions._
class HeaderSessionService(implicit sessionConfig: SessionConfig) {
implicit val sessionManager: SessionManager[UserSession] = new SessionManager[UserSession](sessionConfig)
// モバイルアプリ用ログイン
def mobileLoginRoute: Route =
path("api" / "login") {
post {
entity(as[MobileLoginRequest]) { loginRequest =>
authenticateMobile(loginRequest) match {
case Some(user) =>
val userSession = UserSession(user.id, user.username, user.roles)
setSession(oneOff, usingHeaders, userSession) { ctx =>
complete(LoginResponse(success = true, message = "認証成功"))
}
case None =>
complete((401, LoginResponse(success = false, message = "認証失敗")))
}
}
}
}
// モバイルアプリ用API保護
def mobileApiRoute: Route =
pathPrefix("api") {
requiredSession(oneOff, usingHeaders) { session: UserSession =>
pathPrefix("user") {
get {
complete(UserProfile(session.userId, session.username, session.roles))
} ~
put {
entity(as[UpdateProfileRequest]) { updateRequest =>
// プロファイル更新ロジック
complete("プロファイル更新完了")
}
}
} ~
pathPrefix("data") {
get {
complete(s"${session.username}のデータ一覧")
} ~
post {
entity(as[CreateDataRequest]) { createRequest =>
// データ作成ロジック
complete("データ作成完了")
}
}
}
}
}
// セッション更新(トークンリフレッシュ)
def refreshTokenRoute: Route =
path("api" / "refresh") {
post {
requiredSession(oneOff, usingHeaders) { session: UserSession =>
// セッション延長
setSession(oneOff, usingHeaders, session.copy()) { ctx =>
complete(RefreshResponse(success = true, message = "トークン更新完了"))
}
}
}
}
private def authenticateMobile(loginRequest: MobileLoginRequest): Option[User] = {
// モバイル専用認証ロジック(デバイスID検証等)
validateDeviceId(loginRequest.deviceId) && validateCredentials(loginRequest.username, loginRequest.password) match {
case true => Some(User(loginRequest.username, loginRequest.username, Set("mobile_user")))
case false => None
}
}
private def validateDeviceId(deviceId: String): Boolean = {
// デバイスID検証ロジック
deviceId.nonEmpty && deviceId.length > 10
}
private def validateCredentials(username: String, password: String): Boolean = {
// 認証情報検証ロジック
username.nonEmpty && password.length >= 8
}
}
case class MobileLoginRequest(username: String, password: String, deviceId: String)
case class LoginResponse(success: Boolean, message: String)
case class UserProfile(userId: String, username: String, roles: Set[String])
case class UpdateProfileRequest(name: String, email: String)
case class CreateDataRequest(title: String, content: String)
case class RefreshResponse(success: Boolean, message: String)
JWT統合とクレーム管理
// JWTSessionService.scala - JWT統合セッション
import com.softwaremill.session.{SessionConfig, SessionManager}
import com.softwaremill.session.SessionDirectives._
import com.softwaremill.session.SessionOptions._
import com.softwaremill.session.jwt.{JwtSessionEncoder, JwtSessionManager}
import org.json4s.{DefaultFormats, JValue}
import org.json4s.jackson.JsonMethods._
// JWT用セッションデータ
case class JwtUserSession(
userId: String,
username: String,
roles: Set[String],
issuer: String = "your-app.com",
audience: String = "api.your-app.com"
)
class JWTSessionService(implicit sessionConfig: SessionConfig) {
// JWTセッションエンコーダー
implicit val jwtSessionEncoder: JwtSessionEncoder[JwtUserSession] =
new JwtSessionEncoder[JwtUserSession] {
implicit val formats: DefaultFormats.type = DefaultFormats
def encode(session: JwtUserSession): JValue = {
parse(s"""
{
"sub": "${session.userId}",
"username": "${session.username}",
"roles": [${session.roles.map(role => s""""$role"""").mkString(", ")}],
"iss": "${session.issuer}",
"aud": "${session.audience}",
"iat": ${System.currentTimeMillis() / 1000},
"exp": ${(System.currentTimeMillis() / 1000) + 3600}
}
""")
}
def decode(jwtPayload: JValue): JwtUserSession = {
JwtUserSession(
userId = (jwtPayload \ "sub").extract[String],
username = (jwtPayload \ "username").extract[String],
roles = (jwtPayload \ "roles").extract[List[String]].toSet,
issuer = (jwtPayload \ "iss").extract[String],
audience = (jwtPayload \ "aud").extract[String]
)
}
}
implicit val sessionManager: JwtSessionManager[JwtUserSession] =
new JwtSessionManager[JwtUserSession](sessionConfig)
// JWT認証ルート
def jwtLoginRoute: Route =
path("jwt" / "login") {
post {
entity(as[JwtLoginRequest]) { loginRequest =>
authenticateJWT(loginRequest) match {
case Some(user) =>
val jwtSession = JwtUserSession(
userId = user.id,
username = user.username,
roles = user.roles,
issuer = "your-app.com",
audience = loginRequest.audience.getOrElse("api.your-app.com")
)
setSession(oneOff, usingHeaders, jwtSession) { ctx =>
// JWTトークンをレスポンスヘッダーで返す
complete(JwtLoginResponse(
success = true,
token = ctx.response.headers.find(_.name == "Authorization").map(_.value).getOrElse(""),
expiresIn = 3600,
user = UserInfo(user.id, user.username, user.roles)
))
}
case None =>
complete((401, JwtLoginResponse(success = false, token = "", expiresIn = 0, user = null)))
}
}
}
}
// JWT保護されたAPIルート
def jwtApiRoute: Route =
pathPrefix("jwt" / "api") {
requiredSession(oneOff, usingHeaders) { session: JwtUserSession =>
pathPrefix("profile") {
get {
complete(JwtUserProfile(
userId = session.userId,
username = session.username,
roles = session.roles,
issuer = session.issuer,
audience = session.audience
))
}
} ~
pathPrefix("admin") {
authorizeSession(session, "admin") {
get {
complete("管理者専用データ")
}
}
} ~
pathPrefix("validate") {
post {
complete(JwtValidationResponse(
valid = true,
userId = session.userId,
expiresAt = System.currentTimeMillis() + 3600000 // 1時間後
))
}
}
}
}
// ロールベースアクセス制御
private def authorizeSession(session: JwtUserSession, requiredRole: String): Directive0 = {
if (session.roles.contains(requiredRole)) {
pass
} else {
complete((403, "アクセス権限がありません"))
}
}
// JWTトークン検証エンドポイント
def jwtValidateRoute: Route =
path("jwt" / "validate") {
post {
optionalHeaderValueByName("Authorization") {
case Some(authHeader) if authHeader.startsWith("Bearer ") =>
val token = authHeader.substring(7)
// JWTトークンを手動で検証
validateJwtToken(token) match {
case Some(session) =>
complete(JwtValidationResponse(
valid = true,
userId = session.userId,
expiresAt = System.currentTimeMillis() + 3600000
))
case None =>
complete((401, JwtValidationResponse(valid = false, userId = "", expiresAt = 0)))
}
case _ =>
complete((400, "Authorization ヘッダーが必要です"))
}
}
}
private def authenticateJWT(loginRequest: JwtLoginRequest): Option[User] = {
// JWT認証ロジック
if (loginRequest.username.nonEmpty && loginRequest.password.length >= 6) {
Some(User(loginRequest.username, loginRequest.username, Set("user", "jwt_user")))
} else None
}
private def validateJwtToken(token: String): Option[JwtUserSession] = {
try {
// 実際のJWT検証ロジック(署名検証、有効期限確認等)
// ここでは簡略化
Some(JwtUserSession("test", "testuser", Set("user")))
} catch {
case _: Exception => None
}
}
}
case class JwtLoginRequest(username: String, password: String, audience: Option[String])
case class JwtLoginResponse(success: Boolean, token: String, expiresIn: Long, user: UserInfo)
case class UserInfo(id: String, username: String, roles: Set[String])
case class JwtUserProfile(userId: String, username: String, roles: Set[String], issuer: String, audience: String)
case class JwtValidationResponse(valid: Boolean, userId: String, expiresAt: Long)
CSRF保護とセキュリティ設定
// CSRFProtectionService.scala - CSRF保護実装
import com.softwaremill.session.{SessionConfig, SessionManager}
import com.softwaremill.session.SessionDirectives._
import com.softwaremill.session.SessionOptions._
import com.softwaremill.session.CsrfDirectives._
import com.softwaremill.session.CsrfOptions._
class CSRFProtectionService(implicit sessionConfig: SessionConfig) {
implicit val sessionManager: SessionManager[UserSession] = new SessionManager[UserSession](sessionConfig)
// CSRF保護付きフォーム表示
def formRoute: Route =
path("form") {
get {
// CSRFトークンを生成してフォームに埋め込み
setNewCsrfToken(checkHeader) { ctx =>
val csrfToken = ctx.response.headers.find(_.name == "X-CSRF-Token").map(_.value).getOrElse("")
complete(s"""
<html>
<body>
<form method="post" action="/submit">
<input type="hidden" name="csrf_token" value="$csrfToken"/>
<input type="text" name="data" placeholder="データを入力"/>
<button type="submit">送信</button>
</form>
</body>
</html>
""")
}
}
}
// CSRF保護付きフォーム処理
def submitRoute: Route =
path("submit") {
post {
// CSRFトークン検証とセッション要件
requiredSession(oneOff, usingCookies) { session: UserSession =>
hmacTokenCsrfProtection(checkHeader) {
entity(as[FormData]) { formData =>
// データ処理ロジック
processFormData(session, formData) match {
case Success(result) =>
complete(s"データ処理完了: $result")
case Failure(error) =>
complete((400, s"処理エラー: ${error.getMessage}"))
}
}
}
}
}
}
// API用CSRF保護
def apiWithCSRFRoute: Route =
pathPrefix("api" / "secure") {
requiredSession(oneOff, usingHeaders) { session: UserSession =>
post {
// APIリクエスト用CSRF検証
hmacTokenCsrfProtection(checkHeader) {
entity(as[ApiRequest]) { apiRequest =>
processApiRequest(session, apiRequest) match {
case Success(response) =>
complete(response)
case Failure(error) =>
complete((500, ApiErrorResponse(error.getMessage)))
}
}
}
}
}
}
// セキュリティヘッダー設定
def secureRoute: Route =
extractRequestContext { ctx =>
// セキュリティヘッダー追加
respondWithHeaders(
`Content-Security-Policy`("default-src 'self'; script-src 'self' 'unsafe-inline'"),
`X-Frame-Options`(DENY),
`X-Content-Type-Options`(nosniff),
`Strict-Transport-Security`(31536000, includeSubDomains = true),
RawHeader("X-XSS-Protection", "1; mode=block")
) {
formRoute ~ submitRoute ~ apiWithCSRFRoute
}
}
// セッション無効化(セキュリティ強化)
def secureLogoutRoute: Route =
path("secure" / "logout") {
post {
requiredSession(oneOff, usingCookies) { session: UserSession =>
hmacTokenCsrfProtection(checkHeader) {
// ログアウト処理とセッション無効化
auditLog(s"User ${session.username} logged out securely")
invalidateSession(oneOff, usingCookies) { ctx =>
complete("セキュアログアウト完了")
}
}
}
}
}
private def processFormData(session: UserSession, formData: FormData): Try[String] = {
Try {
// フォームデータ処理ロジック
s"${session.username}のデータ: ${formData.data}"
}
}
private def processApiRequest(session: UserSession, apiRequest: ApiRequest): Try[ApiResponse] = {
Try {
ApiResponse(success = true, data = s"API処理完了: ${apiRequest.action}")
}
}
private def auditLog(message: String): Unit = {
// 監査ログ記録
println(s"[AUDIT] ${java.time.Instant.now()} - $message")
}
}
case class FormData(data: String)
case class ApiRequest(action: String, parameters: Map[String, String])
case class ApiResponse(success: Boolean, data: String)
case class ApiErrorResponse(error: String)
エラーハンドリングとログ管理
// SessionErrorHandler.scala - エラーハンドリング
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.{Directive0, ExceptionHandler, RejectionHandler}
import akka.http.scaladsl.server.Directives._
import com.softwaremill.session.{SessionConfig, SessionManager}
import scala.util.{Failure, Success, Try}
import org.slf4j.LoggerFactory
class SessionErrorHandler(implicit sessionConfig: SessionConfig) {
private val logger = LoggerFactory.getLogger(this.getClass)
// セッション専用例外ハンドラー
implicit val sessionExceptionHandler: ExceptionHandler = ExceptionHandler {
case _: SessionExpiredException =>
logger.warn("Session expired")
complete((StatusCodes.Unauthorized, SessionErrorResponse("セッションの有効期限が切れました", "SESSION_EXPIRED")))
case _: InvalidSessionException =>
logger.warn("Invalid session")
complete((StatusCodes.Unauthorized, SessionErrorResponse("無効なセッションです", "INVALID_SESSION")))
case _: CSRFTokenMismatchException =>
logger.warn("CSRF token mismatch")
complete((StatusCodes.Forbidden, SessionErrorResponse("CSRFトークンが一致しません", "CSRF_MISMATCH")))
case _: SessionNotFoundException =>
logger.info("Session not found")
complete((StatusCodes.Unauthorized, SessionErrorResponse("セッションが見つかりません", "NO_SESSION")))
case ex: SecurityException =>
logger.error("Security exception", ex)
complete((StatusCodes.Forbidden, SessionErrorResponse("セキュリティエラーが発生しました", "SECURITY_ERROR")))
case ex: Exception =>
logger.error("Unexpected error in session handling", ex)
complete((StatusCodes.InternalServerError, SessionErrorResponse("内部エラーが発生しました", "INTERNAL_ERROR")))
}
// セッション専用リジェクションハンドラー
implicit val sessionRejectionHandler: RejectionHandler = RejectionHandler.newBuilder()
.handle {
case SessionRejection(message) =>
logger.warn(s"Session rejection: $message")
complete((StatusCodes.Unauthorized, SessionErrorResponse(message, "SESSION_REJECTION")))
}
.handleNotFound {
complete((StatusCodes.NotFound, SessionErrorResponse("リソースが見つかりません", "NOT_FOUND")))
}
.result()
// セッション監査ディレクティブ
def auditSession: Directive0 = extractClientIP.flatMap { clientIP =>
extractRequestContext.flatMap { ctx =>
val userAgent = ctx.request.headers.find(_.name == "User-Agent").map(_.value).getOrElse("Unknown")
val method = ctx.request.method.value
val uri = ctx.request.uri.toString
logger.info(s"Session request: $method $uri from $clientIP ($userAgent)")
pass
}
}
// セッション検証ディレクティブ
def validateSession: Directive0 =
optionalSession(oneOff, usingCookies).flatMap {
case Some(session: UserSession) =>
if (isValidSession(session)) {
updateSessionLastActivity(session)
pass
} else {
logger.warn(s"Invalid session for user: ${session.username}")
throw new InvalidSessionException("セッションが無効です")
}
case None =>
logger.debug("No session found")
pass
}
// セッション率制限ディレクティブ
def rateLimitBySession: Directive0 =
optionalSession(oneOff, usingCookies).flatMap {
case Some(session: UserSession) =>
if (checkRateLimit(session.userId)) {
pass
} else {
logger.warn(s"Rate limit exceeded for user: ${session.username}")
complete((StatusCodes.TooManyRequests, SessionErrorResponse("リクエスト回数が上限を超えました", "RATE_LIMIT_EXCEEDED")))
}
case None =>
if (checkRateLimitByIP()) {
pass
} else {
logger.warn("Rate limit exceeded for anonymous user")
complete((StatusCodes.TooManyRequests, SessionErrorResponse("リクエスト回数が上限を超えました", "RATE_LIMIT_EXCEEDED")))
}
}
// 包括的セッション保護ディレクティブ
def protectedRoute: Directive0 =
auditSession & validateSession & rateLimitBySession
// セッション健全性チェック
private def isValidSession(session: UserSession): Boolean = {
Try {
// セッションの健全性をチェック
session.userId.nonEmpty &&
session.username.nonEmpty &&
!isSessionBlacklisted(session.userId) &&
!isSessionExpired(session)
}.getOrElse(false)
}
private def updateSessionLastActivity(session: UserSession): Unit = {
// セッションの最終活動時刻を更新
logger.debug(s"Updated last activity for session: ${session.userId}")
}
private def checkRateLimit(userId: String): Boolean = {
// ユーザーごとの率制限チェック
true // 実装では Redis や Hazelcast 等を使用
}
private def checkRateLimitByIP(): Boolean = {
// IPごとの率制限チェック
true // 実装では Redis や Hazelcast 等を使用
}
private def isSessionBlacklisted(userId: String): Boolean = {
// セッションブラックリストチェック
false // 実装では Redis や データベース を使用
}
private def isSessionExpired(session: UserSession): Boolean = {
// セッション有効期限チェック
false // 実装では session に timestamp を含める
}
}
// カスタム例外クラス
class SessionExpiredException(message: String) extends RuntimeException(message)
class InvalidSessionException(message: String) extends RuntimeException(message)
class CSRFTokenMismatchException(message: String) extends RuntimeException(message)
class SessionNotFoundException(message: String) extends RuntimeException(message)
// カスタムリジェクション
case class SessionRejection(message: String) extends akka.http.scaladsl.server.Rejection
// エラーレスポンス
case class SessionErrorResponse(message: String, code: String)