Perfect Sessions (core library) 简体中文
Session storage and session-security building blocks for Perfect, rewritten for Swift 6 and strict concurrency: a session model, pluggable storage backends, and framework-independent CSRF, CORS, cookie and authentication helpers.
This package consolidates what used to be separate Perfect-Session-MySQL/-PostgreSQL/-Redis/-SQLite/-MongoDB
repos into one package with a product per backend; those repos are superseded by this one.
Perfect-Session-CouchDB was not carried forward.
- Swift 6.2 or later (
swift-tools-version: 6.2). Every target builds in Swift 6 language mode. - macOS 12 or later, or Linux. CI builds and tests with Swift 6.2 and 6.4 on Ubuntu 24.04 (every backend against a live server) and with Xcode 26.0.1 and 26.6 on macOS.
- Each backend needs its database's client library; see Storage backends.
Add the package and the backend you want:
dependencies: [
.package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "PerfectSessionCore", package: "Perfect-Session"),
// plus whichever backend driver(s) you need, e.g.:
.product(name: "PerfectSessionMySQL", package: "Perfect-Session"),
]
)
]Only the backends you depend on are built: an app using only the MySQL backend never compiles the others or needs their libraries.
PerfectSessionCore has no external database dependency. It provides:
PerfectSession, the session value: token, user ID, timestamps,data, a CSRF token,isExpired(), andisValid()(expiry plus optional IP-address and user-agent locks);SessionConfig, for cookie, expiry, CSRF and CORS settings;- the
SessionDriverprotocol andMemorySessionDriver, an in-process store for development and tests; SessionCookie, which builds theSet-Cookievalues for the session cookie fromSessionConfig;AuthFilter(which paths require authentication), andCSRFSecurity/CORSSecurity(CSRF token, origin and host checks on header strings).
None of these depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver, and applies the checks.
SessionDriver is an async protocol: create, resume (which also throws), save, destroy,
regenerate, clean and setup. Drivers are Sendable, so one driver instance can be shared across requests.
- Tokens. Session and CSRF tokens are 32 bytes from the system's secure random number generator, as 64 hex
digits (
PerfectSession.newToken()). - Expiry is enforced by the server. Every driver's
resumethrowsInvalidSessionErrorfor a session that has goneidleseconds (SessionConfig.idle, one day by default) without asave(every driver'ssaverenews it), or, if you setSessionConfig.absoluteTimeout, that many seconds after it was created, however active it is. Times are seconds since 1970, so the process's time zone doesn't matter.clean()deletes expired sessions from storage (both limits); the MongoDB TTL index and Redis key TTLs follow both limits too. - Fixation.
resumeonly returns sessions the driver stored, andsaveonly updates a stored session (so a save racing a logout can't bring it back). Callregenerate(_:)when a user signs in or gains privileges, and send the client the new token: it moves the session (user, data, idle time and creation time, so the absolute timeout keeps running) to a new token and CSRF token and destroys the old one. - CSRF. Every session has a CSRF token in
data["csrf"](resumeadds one if a stored session lacks it). Send it to your pages, have state-changing requests send it back (a header or form field), and compare withCSRFSecurity.tokenMatches(_:_:), which takes the same time wherever the tokens differ. Also check theOriginheader withCSRFSecurity.isValid(origin:host:): it compares scheme, host and port, ignoring case and default ports, and never accepts an empty ornullorigin. - Authentication paths.
AuthFilter.requiresAuthentication(_:using:)says whether a request path needs a signed-in user. Paths are compared by/-separated segment, ignoring empty and.segments and (by default,AuthenticationConfig.caseInsensitive) ASCII case, because Perfect-NIO 0.1 routes/ADMINto an/adminhandler.include("/admin")covers/adminand everything below it, but not/administrator;"/admin/*"means the same, and"*"(or"/") covers every path. Exclusions override inclusions.*is a wildcard only as a whole final segment: an inclusion like"*.json"or"/admin*"makes every path require authentication, and such an exclusion matches nothing. When anything is protected, a path with a..segment,%,;or\always requires authentication, since servers decode those differently. Pass the path without its query string. (shouldWeAccept, which returns true when authentication is required, is deprecated.) - CORS.
CORSSecurity.isAcceptable(origin:)matchesSessionConfig.CORS.acceptableHostnamesignoring case; each*in a pattern matches any run of characters. Anchor patterns with the scheme and the full host: a trailing*, as in"https://example.com*", also matcheshttps://example.com.evil.net."*"accepts every origin,nullincluded. Sending theAccess-Control-*headers, and deciding whether to allow credentials, is up to your middleware. - Cookies.
SessionCookie.setCookie(token:)andexpiredCookie()applySessionConfig'sname,cookiePath,cookieDomain,cookieSecure,cookieHTTPOnly(default on) andcookieSameSite(defaultStrict).SameSite=Noneand the__Secure-/__Host-name prefixes (in any case) always addSecure; setcookieSecureyourself when you serve over HTTPS. They return nil instead of a malformed header if a setting or the token holds a character a cookie can't. - Session data is stored as JSON. Keep
datato JSON values (String,Bool, finite numbers,NSNull, and arrays and[String: …]dictionaries of them). A top-level key whose value is anything else, such as aDate, isn't saved, and its name (never its value) is logged; the other keys and the CSRF token are saved. - Logs identify a session by
PerfectSession.logID(_:), a 12-digit SHA-256 prefix of its token, never by the token itself. - Configuration (
SessionConfig,AuthFilter.authenticationConfigand each…SessionConnector) is global state: set it once at startup, before serving requests. The checks also take explicit values (isValid(…, ipAddressLock:userAgentLock:),CSRFSecurity.isValid(…, acceptableHostnames:),CORSSecurity.isAcceptable(…, config:)).
A session filter in Perfect-NIO 0.1's route style:
import PerfectNIO
import PerfectSessionCore
let driver: any SessionDriver = MemorySessionDriver() // or any backend's driver
struct RequestSession: Sendable {
var session: PerfectSession
let isNew: Bool
}
@Sendable func loadSession(_ req: any HTTPRequest) async -> RequestSession {
let ip = req.remoteAddress?.ipAddress ?? ""
let ua = req.headers["User-Agent"].first ?? ""
if let token = req.cookies[SessionConfig.name],
let session = try? await driver.resume(token: token),
session.isValid(ipaddress: ip, useragent: ua) {
return RequestSession(session: session, isNew: false)
}
return RequestSession(session: await driver.create(ipaddress: ip, useragent: ua), isNew: true)
}
let routes: Routes<HTTPRequest, HTTPOutput> = root(loadSession).map { state -> HTTPOutput in
var session = state.session
session.data["visits"] = (session.data["visits"] as? Int ?? 0) + 1
await driver.save(session)
let headers = state.isNew ? SessionCookie.setCookie(token: session.token).map { [("Set-Cookie", $0)] } ?? [] : []
return TextOutput("visits: \(session.data["visits"] ?? 0)", headers: headers)
}Authentication and CSRF checks, after loadSession:
let auth: AuthenticationConfig = {
var config = AuthenticationConfig()
config.include("/account")
config.exclude("/account/login")
return config
}()
@Sendable func checkRequest(_ state: RequestSession, _ req: any HTTPRequest) throws -> RequestSession {
if AuthFilter.requiresAuthentication(req.path, using: auth), state.session.userid.isEmpty {
throw ErrorOutput(status: .unauthorized, description: "Sign in first")
}
if req.method != .GET && req.method != .HEAD {
// The page sent the session's data["csrf"] back in a header (or a form field).
let expected = state.session.data["csrf"] as? String ?? ""
let presented = req.headers["X-CSRF-Token"].first ?? ""
guard CSRFSecurity.tokenMatches(presented, expected),
CSRFSecurity.isValid(origin: req.headers["Origin"].first ?? "",
host: req.headers["Host"].first ?? "") else {
throw ErrorOutput(status: .forbidden, description: "CSRF check failed")
}
}
return state
}
let checked = root(loadSession).request(checkRequest)Each backend is a target and product in this package, with its own test suite. Configure a backend through its
…SessionConnector type, once, at startup.
| Product | Depends on | Needs |
|---|---|---|
PerfectSessionMySQL |
Perfect-MySQL 4 | MySQL client library (libmysqlclient) |
PerfectSessionPostgreSQL |
Perfect-PostgreSQL 5 | libpq |
PerfectSessionRedis |
Perfect-Redis 4, swift-log |
— |
PerfectSessionSQLite |
Perfect-SQLite 6, swift-log |
libsqlite3-dev on Linux |
PerfectSessionMongoDB |
Perfect-MongoDB 4, swift-log |
libmongoc 2 (requirements) |
On macOS, mysql-client and libpq from Homebrew are keg-only: set
PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig.
MySQL, PostgreSQL and SQLite store a session per row in table (default sessions), created by setup().
Every value is bound as a parameter. created, updated and idle are 64-bit (bigint/int8); see
Migrating for tables created before 4.0. MySQLSessionConnector.port must be
1–65535. PostgreSQL connects with PostgreSQLSessionConnector.connectionString(), which percent-encodes the user
name, password and database.
The Redis backend stores each session as JSON under RedisSessionConnector.keyPrefix + token (default prefix
perfect-session:, so a client's token can't name your application's own keys), with a TTL of the session's
idle (or until the absolute timeout, if sooner; at most about 31,700 years), renewed by each save. Each call
opens its own connection. RedisSessionConnector.commandTimeout (seconds, default 5; nil waits indefinitely) is
passed to Perfect-Redis: a call that takes longer throws, its connection is closed, and the driver logs the error
(resume throws InvalidSessionError).
The MongoDB backend stores each session as a document keyed by its token, with data as a subdocument.
Its setup() adds a TTL index, so MongoDB deletes expired sessions itself. Besides MongoDBSessionConnector
(uri, database, collection), you can pass a URI or an existing MongoClientPool to its initializer;
MongoDBSessionDriver() traps if MongoDBSessionConnector.uri isn't a valid connection string, while
init(uri:database:collection:) throws. libmongoc rejects an empty key, and a key is cut short at a NUL, so keys
in data are escaped at every depth: % is stored as %25, NUL as %00, and the empty key as %. Other keys,
$-prefixed and dotted ones included, are stored as is, so queries such as data.role keep working.
The archived Perfect-Session-MongoDB stored each session under its own _id, with the token in a token
field, data as JSON text, and no expiry date. The MongoDB backend can't read those documents, so existing
sessions end when you switch and users sign in again. The TTL index never removes them either, so if you point
the new driver at the old collection, setup() deletes them: documents with a string token, string data,
created, updated and idle fields, and no expiresAt. Run setup() again once no old instances are left
writing to the collection. To keep the old documents, use a different collection.
swift test # core tests; each backend's live suite reports as skippedEach backend's live suite runs when its switch is 1. It drops and recreates its test table or collection, so
point it at a throwaway server:
| Backend | Switch | Connection variables |
|---|---|---|
| MySQL | MYSQL_TESTS |
MYSQL_HOST, MYSQL_PORT (required), MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE |
| PostgreSQL | PG_TESTS |
PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DATABASE |
| Redis | REDIS_TESTS |
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD |
| MongoDB | MONGODB_TESTS |
MONGODB_URI |
| SQLite | SQLITE_TESTS |
— (a temporary file) |
With SESSION_REQUIRE_LIVE=1, as in CI, a backend whose switch isn't set, or whose server can't be reached, fails
the run instead of skipping.
- NUL in user IDs and user agents. PostgreSQL text can't hold a NUL character (the write fails, so the session
isn't stored or saved), and SQLite's text binding stops at it. Session
datais unaffected (it's JSON). AuthFiltersees only the path you pass. It doesn't percent-decode (a path with%requires authentication when anything is protected), and withcaseInsensitiveoff it assumes your router is case-sensitive too.- No TLS settings for MySQL or PostgreSQL.
MySQLSessionConnectorandPostgreSQLSessionConnectorhave no TLS/sslmodeoptions, so the connection uses the client library's defaults (libpq's isprefer, which doesn't verify the server). Use a trusted network, a local socket, or a TLS tunnel. - Database errors aren't logged by the MySQL and PostgreSQL drivers: a failed write is silent.
- MySQL compares tokens with the column's collation, case-insensitively by default; tokens are lowercase hex, so this only means an uppercased token also resumes the session.
- Global configuration. The settings are
nonisolated(unsafe)statics: changing them while requests are being served is a data race. PerfectSession.datais[String: Any].PerfectSessionis@unchecked Sendableon the understanding thatdataholds only JSON values.
4.0 is a rewrite for Swift 6. The 3.x module PerfectSession, with its Perfect-HTTPServer request and response
filters (SessionMemoryDriver, CSRFFilter, isValid(_ request:)), and the separate per-backend repositories are
replaced by this package:
- Depend on
PerfectSessionCoreplus a backend product, instead ofPerfect-Sessionand aPerfect-Session-<Backend>package. PerfectSessionManageris nowSessionDriver, anasyncSendableprotocol that also requirescreate(ipaddress:useragent:)(and hassetup,cleanandregenerate, with default implementations).SessionConfig.couchDatabaseandmongoCollectionare gone: configure the backend's…SessionConnector(MongoDBSessionConnector.collection).cookieSameSiteis aCookieSameSite(wasHTTPCookie.SameSite), andCORS.methodsis[String](was[HTTPMethod]).- These settings still exist but have no effect in 4.0 and are deprecated:
SessionConfig.CSRF.failAction,checkState,checkHeadersandrequireToken;SessionConfig.CORS.methods,customHeaders,withCredentialsandmaxAge;SessionConfig.purgeInterval,healthCheckRouteandisOAuth2;PerfectSession._isOAuth2;AuthenticationConfig.denied. Your middleware does what they used to configure. AuthFilter.shouldWeAcceptis deprecated forrequiresAuthentication, which matches by path segment, ignoring case (see Session security).- There are no HTTP filters. Write a small middleware for your server (see
Using it with Perfect-NIO) that calls a
SessionDriver,SessionCookie,CSRFSecurity,CORSSecurityandAuthFilter. - Drivers are
async:try await driver.resume(token:),await driver.save(_:)(wassave(session:)),await driver.destroy(token:).isValidtakes the client's IP address and user agent as strings. - Logging uses swift-log instead of Perfect-Logger.
- Tokens are 64 hex digits instead of UUIDs, and stored 3.x sessions aren't migrated: users sign in again.
If you used main before 4.0.0:
-
Expired sessions are refused.
resumethrowsInvalidSessionErroronce a session's idle time has run out, instead of returning it for you to check withisValid(). -
Redis keys have a prefix. Sessions are stored under
perfect-session:<token>, so sessions stored bymainaren't found (users sign in again). SetRedisSessionConnector.keyPrefix = ""to keep the old keys, but then a client's token can name any key in that database. -
Redis TTLs follow the session's
idlerather thanSessionConfig.idle, and a session whoseidleis 0 or less is deleted rather than stored without a TTL. -
MemorySessionDriver.saveonly updates a session it has stored, like the other drivers. -
Every
saverenews the idle time (only Redis did);regeneratekeeps the creation time. -
Redis calls time out after 5 seconds by default (
RedisSessionConnector.commandTimeout; set nil for none). -
Session data JSON can't hold loses only the offending keys, not all of
data. -
AuthFiltermatches by segment, ignoring case;CSRFSecurity.isValidcompares scheme and port. -
Tokens are 64 hex digits (they were UUIDs).
-
Wider columns. Tables that
setup()created before 4.0 keep 32-bitcreated/updated/idlecolumns, which can't hold anidlepast about 68 years or times after 2038. Widen them:-- MySQL ALTER TABLE `sessions` MODIFY `created` bigint NOT NULL DEFAULT 0, MODIFY `updated` bigint NOT NULL DEFAULT 0, MODIFY `idle` bigint NOT NULL DEFAULT 0; -- PostgreSQL ALTER TABLE "sessions" ALTER COLUMN "created" TYPE int8, ALTER COLUMN "updated" TYPE int8, ALTER COLUMN "idle" TYPE int8;
The pre-Swift-6 code (3.1.5 plus the later OAuth2 flag) is preserved on the legacy branch.
Stay on 3.x if you can't move to Swift 6.2 yet.