diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1189b5b..00ddff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,25 @@ on: push: pull_request: +permissions: + contents: read + +env: + MONGOC_VERSION: 2.5.5 + jobs: linux: # Every backend against a real server (SQLite uses a local file). - name: Linux (Swift 6.4, all backends) + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) runs-on: ubuntu-24.04 - container: swift:6.4-resolute + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] services: mongo: image: mongo:8 @@ -34,40 +47,100 @@ jobs: MONGODB_URI: mongodb://mongo:27017 PG_TESTS: "1" PG_HOST: postgres + PG_PORT: "5432" MYSQL_TESTS: "1" MYSQL_HOST: mysql + MYSQL_PORT: "3306" REDIS_TESTS: "1" REDIS_HOST: redis + REDIS_PORT: "6379" SQLITE_TESTS: "1" + # Fail, rather than skip, if a backend's tests aren't switched on or can't reach its server. + SESSION_REQUIRE_LIVE: "1" + # Expiry is epoch seconds; it must not depend on the process's time zone. + TZ: America/Denver steps: - uses: actions/checkout@v7 - name: Install client libraries run: | apt-get update - apt-get install -y --no-install-recommends pkg-config libmongoc-dev libbson-dev libpq-dev libmysqlclient-dev libsqlite3-dev + apt-get install -y --no-install-recommends pkg-config libpq-dev libmysqlclient-dev libsqlite3-dev \ + build-essential cmake curl libssl-dev libsasl2-dev libzstd-dev + + # Ubuntu 24.04's libmongoc is 1.x; Perfect-MongoDB 4 needs libmongoc 2. + - name: Cache libmongoc + id: mongoc-cache + uses: actions/cache@v6 + with: + path: /opt/mongoc + key: mongoc-${{ env.MONGOC_VERSION }}-noble + + - name: Build libmongoc + if: steps.mongoc-cache.outputs.cache-hit != 'true' + run: | + curl -fsSL https://github.com/mongodb/mongo-c-driver/releases/download/$MONGOC_VERSION/mongo-c-driver-$MONGOC_VERSION.tar.gz | tar xz + cmake -S mongo-c-driver-$MONGOC_VERSION -B mongoc-build \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/mongoc \ + -DENABLE_TESTS=OFF -DENABLE_EXAMPLES=OFF + cmake --build mongoc-build --parallel + cmake --install mongoc-build + + - name: Use libmongoc + run: | + echo "PKG_CONFIG_PATH=$(dirname "$(find /opt/mongoc -name mongoc2.pc)")" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$(dirname "$(find /opt/mongoc -name 'libmongoc2.so' | head -1)")" >> "$GITHUB_ENV" + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags - name: Test - run: swift test + run: swift test $FLAGS macos: - # GitHub's macOS runners have no database servers, so this covers the build plus the - # server-independent tests, including SQLite (a local file). - name: macOS (build, core and SQLite tests) + # GitHub's macOS runners have no database servers, so this covers the build, the + # server-independent tests and SQLite (a local file); the other live suites report as skipped. + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] env: SQLITE_TESTS: "1" + TZ: America/Denver steps: - uses: actions/checkout@v7 + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + - name: Install client libraries run: | - brew install mongo-c-driver mysql-client libpq - # Keg-only; older SwiftPM releases don't find libpq through its brew provider alone. + brew install mongo-c-driver mysql-client libpq pkgconf + # mysql-client and libpq are keg-only, so pkg-config can't find their .pc files without this. echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV" - - name: Toolchain - run: swift --version + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags - name: Test - run: swift test + run: swift test $FLAGS diff --git a/Package.swift b/Package.swift index df3ede2..3456285 100644 --- a/Package.swift +++ b/Package.swift @@ -13,16 +13,19 @@ let package = Package( .library(name: "PerfectSessionMongoDB", targets: ["PerfectSessionMongoDB"]), ], dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.2"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", from: "4.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", from: "5.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", from: "4.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", from: "6.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ], targets: [ .target( name: "PerfectSessionCore", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ], swiftSettings: [.swiftLanguageMode(.v6)] ), .target( diff --git a/README.md b/README.md index 64ae9e3..e086ef2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

- Swift 6.2 + Swift 6.2+ Platforms macOS 12+ | Linux @@ -13,7 +13,8 @@

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 and authentication checks. +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. @@ -22,16 +23,17 @@ repos into one package with a product per backend; those repos are superseded by ## Requirements - Swift 6.2 or later (`swift-tools-version: 6.2`). Every target builds in Swift 6 language mode. -- macOS 12 or later, or Linux. Every backend builds on Linux (tested with Swift 6.4 on Ubuntu 26.04). -- Each backend needs its database's client library; see the list below. +- 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](#storage-backends). -## Building +## Installation -Add it to your `Package.swift`, with the backend you want: +Add the package and the backend you want: ``` swift dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"), ], targets: [ .target( @@ -45,47 +47,175 @@ targets: [ ] ``` -There is no tagged release of the Swift 6 version yet, so depend on `main`. The `3.x` tags are the pre-rewrite -version. Only the backends you depend on are built: an app using only the MySQL backend never compiles the -others or needs their libraries. +Only the backends you depend on are built: an app using only the MySQL backend never compiles the others or +needs their libraries. + +## What's in the core `PerfectSessionCore` has no external database dependency. It provides: -- `PerfectSession`, the session value (token, user ID, timestamps, `data`, CSRF token, and `isValid()` for - expiry and optional IP/user-agent locks); +- `PerfectSession`, the session value: token, user ID, timestamps, `data`, a CSRF token, `isExpired()`, and + `isValid()` (expiry plus optional IP-address and user-agent locks); - `SessionConfig`, for cookie, expiry, CSRF and CORS settings; - the `SessionDriver` protocol and `MemorySessionDriver`, an in-process store for development and tests; -- `AuthFilter` (path inclusion/exclusion rules), and `CSRFSecurity`/`CORSSecurity` (origin and host checks on - header strings). +- `SessionCookie`, which builds the `Set-Cookie` values for the session cookie from `SessionConfig`; +- `AuthFilter` (which paths require authentication), and `CSRFSecurity`/`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. + +### Session security + +- **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 `resume` throws `InvalidSessionError` for a session that has + gone `idle` seconds (`SessionConfig.idle`, one day by default) without a `save` (every driver's `save` renews it), + or, if you set `SessionConfig.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.** `resume` only returns sessions the driver stored, and `save` only updates a stored session (so a + save racing a logout can't bring it back). Call `regenerate(_:)` 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"]` (`resume` adds 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 with + `CSRFSecurity.tokenMatches(_:_:)`, which takes the same time wherever the tokens differ. Also check the `Origin` + header with `CSRFSecurity.isValid(origin:host:)`: it compares scheme, host and port, ignoring case and default + ports, and never accepts an empty or `null` origin. +- **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 `/ADMIN` to an `/admin` + handler. `include("/admin")` covers `/admin` and 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:)` matches `SessionConfig.CORS.acceptableHostnames` ignoring 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 matches `https://example.com.evil.net`. `"*"` accepts every + origin, `null` included. Sending the `Access-Control-*` headers, and deciding whether to allow credentials, is up + to your middleware. +- **Cookies.** `SessionCookie.setCookie(token:)` and `expiredCookie()` apply `SessionConfig`'s `name`, + `cookiePath`, `cookieDomain`, `cookieSecure`, `cookieHTTPOnly` (default on) and `cookieSameSite` (default + `Strict`). `SameSite=None` and the `__Secure-`/`__Host-` name prefixes (in any case) always add `Secure`; set `cookieSecure` + yourself 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 `data` to 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 a `Date`, + 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.authenticationConfig` and 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:)`). + +### Using it with Perfect-NIO + +A session filter in [Perfect-NIO](https://github.com/PerfectlySoft/Perfect-NIO) 0.1's route style: + +``` swift +import PerfectNIO +import PerfectSessionCore + +let driver: any SessionDriver = MemorySessionDriver() // or any backend's driver -These don't depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver -(`create`, `resume`, `save`, `destroy`), and applies the CSRF, CORS and authentication checks. +struct RequestSession: Sendable { + var session: PerfectSession + let isNew: Bool +} -`SessionDriver` is an `async` protocol: `create`, `resume` (which also `throws`), `save`, `destroy`, `clean` and -`setup`. Drivers are `Sendable`, so one driver instance can be shared across requests. +@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 = 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`: + +``` swift +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) +``` ## Storage backends Each backend is a target and product in this package, with its own test suite. Configure a backend through its -`…SessionConnector` type. Its tests skip unless the switch shown is set to `1`, along with the connection -variables in that test file. +`…SessionConnector` type, once, at startup. + +| Product | Depends on | Needs | +|---|---|---| +| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL client library (`libmysqlclient`) | +| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` | +| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4, `swift-log` | — | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6, `swift-log` | `libsqlite3-dev` on Linux | +| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | -| Product | Depends on | Needs | Tests | -|---|---|---|---| -| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL client library (`libmysqlclient`) | `MYSQL_TESTS` | -| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | -| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis), `swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS, Linux (`libsqlite3-dev` on Linux) | `SQLITE_TESTS` | -| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | +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](#migrating-from-earlier-versions) 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. -`save` only updates a session that still exists, so a save that races a logout can't bring the session back; -a session that was never stored (its `create` failed) or has already expired isn't stored by `save` either. -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 are stored as is, so queries such as -`data.role` keep working. +(`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. ### Migrating from Perfect-Session-MongoDB @@ -96,6 +226,102 @@ the new driver at the old collection, `setup()` deletes them: documents with a s `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`. -## Further information +## Running the tests + +``` sh +swift test # core tests; each backend's live suite reports as skipped +``` + +Each 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. + +## Known limitations + +- **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 `data` is unaffected (it's JSON). +- **`AuthFilter` sees only the path you pass.** It doesn't percent-decode (a path with `%` requires authentication + when anything is protected), and with `caseInsensitive` off it assumes your router is case-sensitive too. +- **No TLS settings for MySQL or PostgreSQL.** `MySQLSessionConnector` and `PostgreSQLSessionConnector` have no + TLS/`sslmode` options, so the connection uses the client library's defaults (libpq's is `prefer`, 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.data` is `[String: Any]`.** `PerfectSession` is `@unchecked Sendable` on the understanding that + `data` holds only JSON values. + +## Migrating from earlier versions + +### From 3.x + +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 `PerfectSessionCore` plus a backend product, instead of `Perfect-Session` and a + `Perfect-Session-` package. +- `PerfectSessionManager` is now `SessionDriver`, an `async` `Sendable` protocol that also requires + `create(ipaddress:useragent:)` (and has `setup`, `clean` and `regenerate`, with default implementations). +- `SessionConfig.couchDatabase` and `mongoCollection` are gone: configure the backend's `…SessionConnector` + (`MongoDBSessionConnector.collection`). `cookieSameSite` is a `CookieSameSite` (was `HTTPCookie.SameSite`), and + `CORS.methods` is `[String]` (was `[HTTPMethod]`). +- These settings still exist but have no effect in 4.0 and are deprecated: `SessionConfig.CSRF.failAction`, + `checkState`, `checkHeaders` and `requireToken`; `SessionConfig.CORS.methods`, `customHeaders`, + `withCredentials` and `maxAge`; `SessionConfig.purgeInterval`, `healthCheckRoute` and `isOAuth2`; + `PerfectSession._isOAuth2`; `AuthenticationConfig.denied`. Your middleware does what they used to configure. +- `AuthFilter.shouldWeAccept` is deprecated for `requiresAuthentication`, which matches by path segment, ignoring + case (see [Session security](#session-security)). +- There are no HTTP filters. Write a small middleware for your server (see + [Using it with Perfect-NIO](#using-it-with-perfect-nio)) that calls a `SessionDriver`, `SessionCookie`, + `CSRFSecurity`, `CORSSecurity` and `AuthFilter`. +- Drivers are `async`: `try await driver.resume(token:)`, `await driver.save(_:)` (was `save(session:)`), + `await driver.destroy(token:)`. `isValid` takes 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. + +### From the Swift 6 `main` branch + +If you used `main` before 4.0.0: + +- **Expired sessions are refused.** `resume` throws `InvalidSessionError` once a session's idle time has run out, + instead of returning it for you to check with `isValid()`. +- **Redis keys have a prefix.** Sessions are stored under `perfect-session:`, so sessions stored by `main` + aren't found (users sign in again). Set `RedisSessionConnector.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 `idle`** rather than `SessionConfig.idle`, and a session whose `idle` is 0 or + less is deleted rather than stored without a TTL. +- **`MemorySessionDriver.save` only updates** a session it has stored, like the other drivers. +- **Every `save` renews the idle time** (only Redis did); `regenerate` keeps 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`. +- **`AuthFilter`** matches by segment, ignoring case; `CSRFSecurity.isValid` compares scheme and port. +- **Tokens are 64 hex digits** (they were UUIDs). +- **Wider columns.** Tables that `setup()` created before 4.0 keep 32-bit `created`/`updated`/`idle` columns, which + can't hold an `idle` past about 68 years or times after 2038. Widen them: + + ``` sql + -- 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; + ``` + +## Legacy -The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. +The pre-Swift-6 code (3.1.5 plus the later OAuth2 flag) is preserved on the [`legacy`](../../tree/legacy) branch. +Stay on 3.x if you can't move to Swift 6.2 yet. diff --git a/README.zh_CN.md b/README.zh_CN.md index 348fa88..8b4cc4e 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -2,7 +2,7 @@

- Swift 6.2 + Swift 6.2+ Platforms macOS 12+ | Linux @@ -13,24 +13,25 @@

Perfect 的会话存储与会话安全基础组件,针对 Swift 6 与严格并发检查重写:包括会话模型、可插拔的存储后端, -以及不依赖具体 Web 框架的 CSRF、CORS 与身份验证检查。 +以及不依赖具体 Web 框架的 CSRF、CORS、Cookie 与身份验证辅助功能。 本包把过去分散的 `Perfect-Session-MySQL`/`-PostgreSQL`/`-Redis`/`-SQLite`/`-MongoDB` 仓库**合并**为一个包, 每个后端对应一个产品;上述仓库均已被本包取代。`Perfect-Session-CouchDB` 没有被沿用。 +完整说明(包括 Perfect-NIO 示例和迁移细节)以 [英文 README](README.md) 为准。 + ## 环境要求 - Swift 6.2 或更新版本(`swift-tools-version: 6.2`)。所有目标都在 Swift 6 语言模式下编译。 -- macOS 12 或更新版本,或 Linux。所有后端都可以在 Linux 上编译(已用 Ubuntu 26.04 上的 Swift 6.4 测试)。 +- macOS 12 或更新版本,或 Linux。CI 在 Ubuntu 24.04 上用 Swift 6.2 和 6.4 编译并测试(每个后端都连接真实服务器), + 并在 macOS 上用 Xcode 26.0.1 和 26.6 测试。 - 每个后端都需要对应数据库的客户端库,详见下方列表。 -## 构建 - -把它加入你的 `Package.swift`,并加上你需要的后端: +## 安装 ``` swift dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"), ], targets: [ .target( @@ -44,51 +45,73 @@ targets: [ ] ``` -Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分支。`3.x` 标签是重写之前的版本。只有你所依赖的后端 -才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。 +只有你所依赖的后端才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。 + +## 核心库 `PerfectSessionCore` 不依赖任何外部数据库。它提供: -- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌,以及用于检查过期和可选的 IP/用户代理 - 绑定的 `isValid()`); +- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌、`isExpired()`,以及检查过期和可选的 + IP/用户代理绑定的 `isValid()`); - `SessionConfig`:Cookie、过期、CSRF 与 CORS 设置; - `SessionDriver` 协议和 `MemorySessionDriver`,后者是用于开发和测试的进程内存储; +- `SessionCookie`:根据 `SessionConfig` 生成会话 Cookie 的 `Set-Cookie` 值; - `AuthFilter`(路径包含/排除规则),以及 `CSRFSecurity`/`CORSSecurity`(对请求头字符串进行来源和主机检查)。 -这些组件不依赖任何 HTTP 框架。由你的服务器中间件读取会话 Cookie、调用驱动(`create`、`resume`、`save`、 -`destroy`),并执行 CSRF、CORS 与身份验证检查。 - -`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、`destroy`、`clean` 和 -`setup`。驱动遵循 `Sendable`,因此同一个驱动实例可以在多个请求之间共享。 +这些组件不依赖任何 HTTP 框架。`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、 +`destroy`、`regenerate`、`clean` 和 `setup`。驱动遵循 `Sendable`,同一个驱动实例可以在多个请求之间共享。 + +### 会话安全 + +- **令牌**:会话令牌和 CSRF 令牌取自系统的安全随机数生成器(32 字节,64 个十六进制字符)。 +- **过期由服务器强制执行**:会话空闲超过 `idle` 秒(所有驱动的 `save` 都会续期),或设置了 + `SessionConfig.absoluteTimeout` 且自创建起已超过该秒数时,所有驱动的 `resume` 都会抛出 `InvalidSessionError`。 + 时间以 1970 年以来的秒数存储,与进程时区无关。 +- **会话固定**:`resume` 只返回驱动已存储的会话,`save` 只更新已存储的会话。用户登录或权限变化时调用 + `regenerate(_:)`,并把新令牌发给客户端。 +- **Cookie**:`SessionCookie.setCookie(token:)` 与 `expiredCookie()` 使用 `SessionConfig` 的 Cookie 设置 + (默认 `HttpOnly`、`SameSite=Strict`);`SameSite=None` 和 `__Secure-`/`__Host-` 前缀总会加上 `Secure`。 +- **CSRF**:用 `CSRFSecurity.tokenMatches(_:_:)` 比较请求带回的令牌与 `data["csrf"]`,并用 + `CSRFSecurity.isValid(origin:host:)` 检查 `Origin`(比较协议、主机和端口,忽略大小写和默认端口)。 +- **需要登录的路径**:`AuthFilter.requiresAuthentication(_:using:)` 按 `/` 分段、默认忽略大小写进行匹配; + `"/admin"` 覆盖其下所有路径但不覆盖 `/administrator`。`shouldWeAccept` 已弃用。 +- **会话数据**以 JSON 存储,`data` 只能存放 JSON 值;无法存为 JSON 的键不会被保存(只记录键名),其他键和 CSRF + 令牌照常保存。日志中只记录令牌的 SHA-256 前缀(`PerfectSession.logID`),不记录令牌本身。 +- **配置**是全局状态,请在启动时设置一次,然后再开始处理请求。 ## 存储后端 -每个后端都是本包中的一个目标和产品,并有各自的测试套件。通过对应的 `…SessionConnector` 类型配置后端。测试默认 -会跳过,除非把表中所示的开关设为 `1`,并设置该测试文件中的连接变量。 - -| 产品 | 依赖 | 需要 | 测试 | -|---|---|---|---| -| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL 客户端库(`libmysqlclient`) | `MYSQL_TESTS` | -| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` | -| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis)、`swift-log` | — | `REDIS_TESTS` | -| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS、Linux(Linux 上需要 `libsqlite3-dev`) | `SQLITE_TESTS` | -| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` | +| 产品 | 依赖 | 需要 | +|---|---|---| +| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL 客户端库(`libmysqlclient`) | +| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` | +| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4、`swift-log` | — | +| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6、`swift-log` | Linux 上需要 `libsqlite3-dev` | +| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | -**MongoDB** 后端把每个会话存储为一个以其令牌为键的文档,`data` 以子文档形式存储。它的 `setup()` 会添加 TTL -索引,让 MongoDB 自动删除过期会话。除了 `MongoDBSessionConnector`(`uri`、`database`、`collection`)之外, -你也可以向其初始化方法传入 URI 或现有的 `MongoClientPool`。 -`save` 只会更新仍然存在的会话,因此与注销同时发生的保存不会让会话复活;从未存储成功(其 `create` 失败)或已经过期的 -会话,`save` 也不会再存储。libmongoc 拒绝空键,而键会在 NUL 处被截断,因此 `data` 中各层的键都会被转义:`%` -存为 `%25`,NUL 存为 `%00`,空键存为 `%`。其他键原样存储,因此 `data.role` 之类的查询照常可用。 +**MySQL、PostgreSQL 与 SQLite** 后端每个会话一行,所有值都以参数绑定;`created`、`updated`、`idle` 为 64 位列。 +**Redis** 后端把会话以 JSON 存在 `RedisSessionConnector.keyPrefix`(默认 `perfect-session:`)加令牌的键下, +TTL 为会话的 `idle`;`RedisSessionConnector.commandTimeout`(默认 5 秒,设为 nil 则不限)会传给 Perfect-Redis,超时的调用会抛出错误并关闭连接。 +**MongoDB** 后端每个会话一个文档,`data` 为子文档,`setup()` 会添加 TTL 索引;`data` 中的空键、`%` 和 NUL 会被转义。 ### 从 Perfect-Session-MongoDB 迁移 -已归档的 `Perfect-Session-MongoDB` 把每个会话存储在它自己的 `_id` 下,令牌放在 `token` 字段中,`data` 是 JSON -文本,并且没有过期时间。MongoDB 后端无法读取这些文档,因此切换后现有会话都会结束,用户需要重新登录。TTL 索引也 -永远不会删除它们,所以如果你让新驱动使用旧的集合,`setup()` 会删除这些文档:即 `token` 为字符串、`data` 为字符串、 -含有 `created`、`updated` 和 `idle` 字段且没有 `expiresAt` 的文档。等不再有旧实例写入该集合后,再运行一次 -`setup()`。如果想保留旧文档,请使用另一个 `collection`。 +已归档的 `Perfect-Session-MongoDB` 的文档无法被新后端读取,切换后现有会话都会结束。如果新驱动使用旧集合, +`setup()` 会删除这些旧文档(`token` 与 `data` 为字符串、含 `created`/`updated`/`idle` 且没有 `expiresAt`)。 +如想保留旧文档,请使用另一个 `collection`。 + +## 运行测试 + +各后端的实时测试在对应开关为 `1` 时运行:`MYSQL_TESTS`(还需 `MYSQL_PORT`)、`PG_TESTS`、`REDIS_TESTS`、 +`MONGODB_TESTS`、`SQLITE_TESTS`;连接变量见英文 README。设置 `SESSION_REQUIRE_LIVE=1`(CI 中如此)时, +未开启或无法连接服务器的后端会导致测试失败,而不是跳过。 + +## 迁移 + +从 3.x 迁移:模块与 HTTP 过滤器已被替换,驱动改为 `async`,需要自己编写中间件。从 4.0.0 之前的 `main` 迁移: +过期会话会被拒绝、Redis 键加了前缀、令牌改为 64 个十六进制字符、旧表需要把时间列改为 64 位。详见英文 README 的 +“Migrating from earlier versions”一节。 -## 更多信息 +## 旧版本 -本包在 Swift 6 之前的版本保留在 [`legacy`](../../tree/legacy) 分支上。 +本包在 Swift 6 之前的代码(3.1.5 加上之后的 OAuth2 标志)保留在 [`legacy`](../../tree/legacy) 分支上。 diff --git a/Sources/PerfectSessionCore/AuthFilter.swift b/Sources/PerfectSessionCore/AuthFilter.swift index 1adcc2c..051960d 100644 --- a/Sources/PerfectSessionCore/AuthFilter.swift +++ b/Sources/PerfectSessionCore/AuthFilter.swift @@ -1,10 +1,17 @@ import Foundation -/// Configures which URL paths require authentication. +/// Configures which URL paths require authentication. See `AuthFilter.requiresAuthentication`. public struct AuthenticationConfig: Sendable { + /// Paths that require authentication. `"/admin"` covers `/admin` and everything below it + /// (not `/administrator`); `"/admin/*"` means the same; `"*"` covers every path. public var inclusions = [String]() + /// Paths that don't, overriding `inclusions`, with the same matching. public var exclusions = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Redirect or respond in your middleware.") public var denied: String? + /// Compare paths ignoring ASCII case (the default), for servers whose routing ignores + /// case, as Perfect-NIO 0.1's does. + public var caseInsensitive = true public init() {} @@ -14,30 +21,68 @@ public struct AuthenticationConfig: Sendable { public mutating func exclude(_ arr: [String]) { exclusions += arr } } -/// Pure-string path matching for authentication gating (no HTTP types needed). +/// Path matching for authentication gating (no HTTP types needed). public struct AuthFilter: Sendable { nonisolated(unsafe) public static var authenticationConfig = AuthenticationConfig() - /// Checks the global authenticationConfig; pass `config` to use a local config instead. - public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { + /// Whether **path** requires authentication under **config** (by default + /// `authenticationConfig`). Pass the request's path without its query string. + /// + /// The path is split on `/`, empty and `.` segments are dropped, and segments are compared + /// ignoring case unless `caseInsensitive` is off. A pattern matches its own path and every + /// path below it. `*` is a wildcard only as a pattern's whole final segment; an inclusion + /// that uses `*` any other way (`"*.json"`, `"/admin*"`) makes every path require + /// authentication, and such an exclusion matches nothing. + /// + /// When any inclusion is configured, a path containing a `..` segment, `%`, `;` or `\` + /// requires authentication whatever the patterns say: different servers decode and + /// normalize those differently, so the path can't be matched reliably. + public static func requiresAuthentication(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { let cfg = config ?? authenticationConfig - var checkAuth = false - let wildcardInclusions = cfg.inclusions.filter { $0.contains("*") } - let wildcardExclusions = cfg.exclusions.filter { $0.contains("*") } - - if cfg.inclusions.contains(path) { checkAuth = true } - for wInc in wildcardInclusions { - if wInc == "*" { checkAuth = true } - else if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) { - checkAuth = true + guard !cfg.inclusions.isEmpty else { return false } + if path.contains(where: { $0 == "%" || $0 == ";" || $0 == "\\" }) { return true } + guard let segments = normalize(path, caseInsensitive: cfg.caseInsensitive) else { return true } + var required = false + for pattern in cfg.inclusions { + switch match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) { + case .matches, .invalid: required = true + case .noMatch: break } + if required { break } } - if cfg.exclusions.contains(path) { checkAuth = false } - for wInc in wildcardExclusions { - if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) { - checkAuth = false - } + guard required else { return false } + for pattern in cfg.exclusions + where match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) == .matches { + return false } - return checkAuth + return true + } + + /// True when **path** requires authentication: the same as `requiresAuthentication`. + @available(*, deprecated, renamed: "requiresAuthentication(_:using:)", + message: "Returns true when authentication is REQUIRED; use requiresAuthentication(_:using:).") + public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool { + requiresAuthentication(path, using: config) + } + + enum Match { case matches, noMatch, invalid } + + /// Path segments without empty and `.` ones, or nil if there's a `..` segment. + static func normalize(_ path: String, caseInsensitive: Bool) -> [Substring]? { + let text = caseInsensitive ? path.lowercased() : path + var segments: [Substring] = [] + for segment in text.split(separator: "/", omittingEmptySubsequences: true) where segment != "." { + if segment == ".." { return nil } + segments.append(segment) + } + return segments + } + + static func match(_ segments: [Substring], pattern: String, caseInsensitive: Bool) -> Match { + guard var parts = normalize(pattern, caseInsensitive: caseInsensitive) else { return .invalid } + if parts.last == "*" { parts.removeLast() } + guard !parts.contains(where: { $0.contains("*") }) else { return .invalid } + guard segments.count >= parts.count else { return .noMatch } + return zip(segments, parts).allSatisfy { $0 == $1 } ? .matches : .noMatch } } diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift index 9c327de..86f7a74 100644 --- a/Sources/PerfectSessionCore/CSRFSecurity.swift +++ b/Sources/PerfectSessionCore/CSRFSecurity.swift @@ -4,17 +4,67 @@ import Foundation /// values from the request and passes them as strings to these helpers. public struct CSRFSecurity: Sendable { - public static func isValid(origin: String, host: String) -> Bool { - let cleanOrigin = stripScheme(origin) - if cleanOrigin.isEmpty { return false } - if !SessionConfig.CSRF.acceptableHostnames.isEmpty { - for check in SessionConfig.CSRF.acceptableHostnames { - if check == cleanOrigin { return true } + /// True if **origin** (the request's `Origin` header) names the same origin as **host** (its + /// `Host` header) or as an entry in **acceptableHostnames** (by default + /// `SessionConfig.CSRF.acceptableHostnames`). + /// + /// Comparison ignores case and treats a default port (`:443` for https, `:80` for http) like + /// no port. When both sides give a scheme, the schemes must match; a side without a scheme + /// (a `Host` header never has one) takes the other side's. An empty or `null` origin never + /// matches. + public static func isValid(origin: String, host: String, + acceptableHostnames: [String] = SessionConfig.CSRF.acceptableHostnames) -> Bool { + let o = Endpoint(origin) + guard !o.authority.isEmpty, o.authority != "null" || o.scheme != nil else { return false } + for accepted in acceptableHostnames where Endpoint(accepted).matches(o) { return true } + let h = Endpoint(host) + guard !h.authority.isEmpty else { return false } + return h.matches(o) + } + + /// An origin or host: an optional scheme and a lowercased host[:port]. + struct Endpoint { + let scheme: String? + let authority: String + + init(_ text: String) { + var rest = Substring(text.lowercased()) + if let range = rest.range(of: "://") { + scheme = String(rest[.. Bool { + if let a = scheme, let b = other.scheme, a != b { return false } + let effective = scheme ?? other.scheme + return Self.strip(authority, effective) == Self.strip(other.authority, effective) + } + + static func strip(_ authority: String, _ scheme: String?) -> String { + let suffix: String + switch scheme { + case "https": suffix = ":443" + case "http": suffix = ":80" + default: return authority + } + return authority.hasSuffix(suffix) ? String(authority.dropLast(suffix.count)) : authority + } + } + + /// Compares a CSRF token from the request (a header or form field) with the session's + /// (`session.data["csrf"]`) in time that doesn't depend on where they differ. An empty token + /// never matches. + public static func tokenMatches(_ presented: String, _ expected: String) -> Bool { + let a = Array(presented.utf8), b = Array(expected.utf8) + guard !b.isEmpty, a.count == b.count else { return false } + var difference: UInt8 = 0 + for i in 0.. String { @@ -27,18 +77,37 @@ public struct CSRFSecurity: Sendable { /// Validates CORS origin against SessionConfig.CORS.acceptableHostnames. public struct CORSSecurity: Sendable { - public static func isAcceptable(origin: String) -> Bool { - guard SessionConfig.CORS.enabled, !SessionConfig.CORS.acceptableHostnames.isEmpty else { + /// True if **origin** matches an entry in **config**'s `acceptableHostnames` (by default + /// `SessionConfig.CORS`'s), ignoring + /// case. `"*"` accepts every origin; in any other entry each `*` matches any run of + /// characters, and the rest must match in full (`"https://*.example.com"`). Anchor patterns + /// with the scheme and the whole host: a trailing `*` (`"https://example.com*"`) also matches + /// `https://example.com.evil.net`, and `"*"` accepts any origin, `null` included. Sending the + /// `Access-Control-*` headers, credentials included, is the caller's job. + public static func isAcceptable(origin: String, config: SessionConfig.CORSconfig = SessionConfig.CORS) -> Bool { + guard config.enabled, !config.acceptableHostnames.isEmpty else { return false } let lower = origin.lowercased() - if SessionConfig.CORS.acceptableHostnames.contains("*") { return true } - if SessionConfig.CORS.acceptableHostnames.contains(lower) { return true } - for pattern in SessionConfig.CORS.acceptableHostnames where pattern.contains("*") && pattern != "*" { - let parts = pattern.split(separator: "*") - if let first = parts.first, lower.starts(with: String(first)) { return true } - if let last = parts.last, lower.hasSuffix(String(last)) { return true } + for pattern in config.acceptableHostnames { + if pattern == "*" || matches(lower, pattern: pattern.lowercased()) { return true } } return false } + + /// Glob match where `*` is the only wildcard. Every literal piece must match, in order, with + /// the first anchored at the start and the last at the end. + static func matches(_ text: String, pattern: String) -> Bool { + let pieces = pattern.split(separator: "*", omittingEmptySubsequences: false) + guard pieces.count > 1 else { return text == pattern } + var rest = Substring(text) + guard let first = pieces.first, rest.hasPrefix(first) else { return false } + rest = rest.dropFirst(first.count) + guard let last = pieces.last else { return false } + for piece in pieces.dropFirst().dropLast() where !piece.isEmpty { + guard let range = rest.range(of: piece) else { return false } + rest = rest[range.upperBound...] + } + return rest.count >= last.count && rest.hasSuffix(last) + } } diff --git a/Sources/PerfectSessionCore/LogID.swift b/Sources/PerfectSessionCore/LogID.swift new file mode 100644 index 0000000..fa90b41 --- /dev/null +++ b/Sources/PerfectSessionCore/LogID.swift @@ -0,0 +1,62 @@ +extension PerfectSession { + /// An identifier for **token** that is safe to log: the first 12 hex digits of its SHA-256. + /// It tells log lines about the same session apart without letting a log reader take over the + /// session. The drivers log this, never the token. + public static func logID(_ token: String) -> String { + String(sha256Hex(token).prefix(12)) + } + + /// SHA-256 of **text**'s UTF-8 bytes, as 64 lowercase hex digits. (FIPS 180-4; small and + /// self-contained so the core needs no crypto dependency.) + static func sha256Hex(_ text: String) -> String { + var h: [UInt32] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19] + var message = Array(text.utf8) + let bitLength = UInt64(message.count) &* 8 + message.append(0x80) + while message.count % 64 != 56 { message.append(0) } + for shift in stride(from: 56, through: 0, by: -8) { message.append(UInt8(truncatingIfNeeded: bitLength >> UInt64(shift))) } + var w = [UInt32](repeating: 0, count: 64) + for chunk in stride(from: 0, to: message.count, by: 64) { + for i in 0..<16 { + let b = chunk + i * 4 + w[i] = UInt32(message[b]) << 24 | UInt32(message[b + 1]) << 16 | UInt32(message[b + 2]) << 8 | UInt32(message[b + 3]) + } + for i in 16..<64 { + let s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3) + let s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10) + w[i] = w[i - 16] &+ s0 &+ w[i - 7] &+ s1 + } + var (a, b, c, d, e, f, g, hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]) + for i in 0..<64 { + let s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) + let ch = (e & f) ^ (~e & g) + let t1 = hh &+ s1 &+ ch &+ k[i] &+ w[i] + let s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) + let maj = (a & b) ^ (a & c) ^ (b & c) + let t2 = s0 &+ maj + (hh, g, f, e, d, c, b, a) = (g, f, e, d &+ t1, c, b, a, t1 &+ t2) + } + for (i, v) in [a, b, c, d, e, f, g, hh].enumerated() { h[i] = h[i] &+ v } + } + let digits = Array("0123456789abcdef".utf8) + var out = [UInt8]() + for word in h { + for shift in stride(from: 28, through: 0, by: -4) { out.append(digits[Int((word >> UInt32(shift)) & 0xf)]) } + } + return String(decoding: out, as: UTF8.self) + } + + private static func rotr(_ x: UInt32, _ n: UInt32) -> UInt32 { (x >> n) | (x << (32 - n)) } + + private static let k: [UInt32] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ] +} diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift index a5954be..ceac80d 100644 --- a/Sources/PerfectSessionCore/MemorySessionDriver.swift +++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift @@ -1,6 +1,7 @@ import Foundation /// Thread-safe in-memory session store. Useful for development and testing. +/// `@unchecked Sendable`: every access to `sessions` holds `lock`. public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { private var sessions = [String: PerfectSession]() private let lock = NSLock() @@ -9,7 +10,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" @@ -21,23 +22,35 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable { public func resume(token: String) async throws -> PerfectSession { let found = lock.withLock { sessions[token] } - guard var session = found else { throw InvalidSessionError() } + guard var session = found, !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one return session } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't stored (destroyed, cleaned, or never + /// created by this driver) isn't stored again, so a save racing a logout can't bring it back. public func save(_ session: PerfectSession) async { - lock.withLock { sessions[session.token] = session } + var session = session + session.touch() + lock.withLock { + if sessions[session.token] != nil { sessions[session.token] = session } + } } public func destroy(token: String) async { _ = lock.withLock { sessions.removeValue(forKey: token) } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { let now = Int(Date().timeIntervalSince1970) lock.withLock { - sessions = sessions.filter { _, s in (s.updated + s.idle) > now } + sessions = sessions.filter { _, s in !s.isExpired(at: now, absoluteTimeout: absoluteTimeout) } } } } diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift index bce6a22..a0af10f 100644 --- a/Sources/PerfectSessionCore/PerfectSession.swift +++ b/Sources/PerfectSessionCore/PerfectSession.swift @@ -1,17 +1,29 @@ import Foundation +import Logging -/// A session token/data container. @unchecked Sendable because data:[String:Any] is JSON-safe. +private let logger = Logger(label: "perfect.session") + +/// A session: its token, user, timestamps and data. +/// +/// `@unchecked Sendable` because `data` is `[String: Any]`. That holds only while `data` holds JSON +/// values (see `data`), which are value types or immutable Foundation objects; don't put a +/// mutable reference type in it. public struct PerfectSession: @unchecked Sendable { public var token = "" public var userid = "" public var created = 0 public var updated = 0 public var idle = SessionConfig.idle + /// The session's values. Store only JSON values: `String`, `Bool`, finite numbers, `NSNull`, + /// and arrays and `[String: …]` dictionaries of them. A top-level key whose value holds + /// anything else (a `Date`, a `URL`, NaN, a dictionary with non-string keys) isn't saved; its + /// name is logged. See `jsonSafeData()`. public var data = [String: Any]() public var ipaddress = "" public var useragent = "" public var _state = "recover" - public var _isOAuth2 = SessionConfig.isOAuth2 + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it, and it isn't stored.") + public var _isOAuth2 = false public init() { let now = Int(Date().timeIntervalSince1970) @@ -19,8 +31,34 @@ public struct PerfectSession: @unchecked Sendable { updated = now } + /// `data` without the top-level keys whose values JSON can't hold, which are logged by name + /// (never by value). The CSRF token is never dropped: if it isn't a JSON value, a new one + /// replaces it. + public func jsonSafeData() -> [String: Any] { + // JSONSerialization raises an Objective-C exception for such values, which `try?` can't + // catch and which terminates the process, so check each one first. + guard !JSONSerialization.isValidJSONObject(data) else { return data } + var safe = [String: Any]() + var dropped = [String]() + for (key, value) in data { + if JSONSerialization.isValidJSONObject([key: value]) { + safe[key] = value + } else if key == "csrf" { + safe[key] = PerfectSession.newToken() + } else { + dropped.append(key) + } + } + if !dropped.isEmpty { + logger.warning("session data not saved: values JSON can't hold", + metadata: ["keys": .array(dropped.sorted().map { .string($0) })]) + } + return safe + } + + /// `jsonSafeData()` as JSON text. public func tojson() -> String { - guard let d = try? JSONSerialization.data(withJSONObject: data), + guard let d = try? JSONSerialization.data(withJSONObject: jsonSafeData()), let s = String(data: d, encoding: .utf8) else { return "{}" } return s } @@ -31,22 +69,72 @@ public struct PerfectSession: @unchecked Sendable { data = obj } + /// When the session expires (seconds since 1970): `updated + idle`, clamped instead of + /// overflowing, so an `idle` of `Int.max` means "never". + public var expires: Int { + let (sum, overflow) = updated.addingReportingOverflow(idle) + guard overflow else { return sum } + return idle > 0 ? Int.max : Int.min + } + public mutating func touch() { updated = Int(Date().timeIntervalSince1970) } - /// Returns false if the session has expired or IP/UA locks fail. - public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "") -> Bool { - let now = Int(Date().timeIntervalSince1970) - guard (updated + idle) > now else { return false } - if SessionConfig.IPAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } - if SessionConfig.userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } + /// True once the session has been idle for `idle` seconds, or (when **absoluteTimeout** is + /// positive) once that many seconds have passed since it was created, however active it was. + /// Every driver's `resume` refuses an expired session. + public func isExpired(at now: Int = Int(Date().timeIntervalSince1970), + absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Bool { + expiry(absoluteTimeout: absoluteTimeout) <= now + } + + /// When the session expires (seconds since 1970), taking **absoluteTimeout** into account: + /// the earlier of `expires` and `created + absoluteTimeout` (when that's positive). + public func expiry(absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Int { + guard absoluteTimeout > 0 else { return expires } + let (end, overflow) = created.addingReportingOverflow(absoluteTimeout) + return overflow ? expires : min(expires, end) + } + + /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. The locks + /// default to `SessionConfig.IPAddressLock` and `SessionConfig.userAgentLock`. + public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "", + ipAddressLock: Bool = SessionConfig.IPAddressLock, + userAgentLock: Bool = SessionConfig.userAgentLock) -> Bool { + guard !isExpired() else { return false } + if ipAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false } + if userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false } return true } + /// A new session or CSRF token: 32 bytes (256 bits) from the system's secure random number + /// generator, as 64 lowercase hex digits. + /// + /// Not `UUID()`: on Linux, Foundation's UUID reads /dev/urandom and ignores a failed open, + /// so in a process out of file descriptors every UUID is 00000000-0000-4000-8000-000000000000. + /// `SystemRandomNumberGenerator` uses getrandom(2) or arc4random_buf(3) and never returns + /// unfilled bytes. + public static func newToken() -> String { + var generator = SystemRandomNumberGenerator() + let hex = Array("0123456789abcdef".utf8) + var digits = [UInt8]() + digits.reserveCapacity(64) + for _ in 0..<4 { + var word = generator.next() + for _ in 0..<8 { + let byte = UInt8(truncatingIfNeeded: word) + digits.append(hex[Int(byte >> 4)]) + digits.append(hex[Int(byte & 0x0f)]) + word >>= 8 + } + } + return String(decoding: digits, as: UTF8.self) + } + public mutating func setCSRF() { if (data["csrf"] as? String ?? "").isEmpty { - data["csrf"] = UUID().uuidString + data["csrf"] = PerfectSession.newToken() } } } diff --git a/Sources/PerfectSessionCore/SessionConfig.swift b/Sources/PerfectSessionCore/SessionConfig.swift index 9eec73d..89e1782 100644 --- a/Sources/PerfectSessionCore/SessionConfig.swift +++ b/Sources/PerfectSessionCore/SessionConfig.swift @@ -13,11 +13,18 @@ public struct SessionConfig { nonisolated(unsafe) public static var cookieSecure = false nonisolated(unsafe) public static var cookieHTTPOnly = true nonisolated(unsafe) public static var cookieSameSite: CookieSameSite = .strict + /// Seconds a session lasts without being saved (each save renews it). nonisolated(unsafe) public static var idle = 86400 + /// Seconds a session lasts from its creation however active it is, or 0 (the default) for no + /// limit. Checked by `PerfectSession.isExpired()`, `isValid()` and every driver's `resume`. + nonisolated(unsafe) public static var absoluteTimeout = 0 nonisolated(unsafe) public static var userAgentLock = false nonisolated(unsafe) public static var IPAddressLock = false + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Exclude your health-check path in AuthenticationConfig.") nonisolated(unsafe) public static var healthCheckRoute = "/healthcheck" + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Call your driver's clean() on your own schedule.") nonisolated(unsafe) public static var purgeInterval = 3600 + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it.") nonisolated(unsafe) public static var isOAuth2 = false nonisolated(unsafe) public static var CSRF = CSRFconfig() @@ -25,10 +32,15 @@ public struct SessionConfig { public struct CSRFconfig: Sendable { public init() {} + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Your middleware decides what a failed check does.") public var failAction: CSRFaction = .fail + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.") public var checkState = true + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check Origin/Host with CSRFSecurity.isValid in your middleware.") public var checkHeaders = true + /// Origins accepted by `CSRFSecurity.isValid` besides the request's own host. public var acceptableHostnames = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.") public var requireToken = true } @@ -38,9 +50,13 @@ public struct SessionConfig { /// Accept "*" to allow all origins, or list specific hostnames. public var acceptableHostnames = [String]() /// HTTP method strings, e.g. ["GET", "POST"] + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Methods from your middleware.") public var methods: [String] = ["GET"] + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Headers from your middleware.") public var customHeaders = [String]() + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Credentials from your middleware.") public var withCredentials = false + @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Max-Age from your middleware.") public var maxAge = 0 } diff --git a/Sources/PerfectSessionCore/SessionCookie.swift b/Sources/PerfectSessionCore/SessionCookie.swift new file mode 100644 index 0000000..407733d --- /dev/null +++ b/Sources/PerfectSessionCore/SessionCookie.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Builds `Set-Cookie` header values for the session token from `SessionConfig`'s cookie +/// settings, so middleware doesn't assemble the attributes by hand. +public enum SessionCookie { + + /// The `Set-Cookie` value that stores **token**: `name=token; Path=…; [Domain=…;] HttpOnly; + /// Secure; SameSite=…`, from `SessionConfig`. It has no `Expires`/`Max-Age`, so the browser + /// drops it when it closes; the server enforces `idle` and `absoluteTimeout` itself. + /// + /// `SameSite=None` always adds `Secure` (browsers reject it otherwise), and so does a name + /// starting with `__Secure-` or `__Host-` (in any case). Returns nil, rather than a header a browser would + /// misread, if the name isn't a valid cookie name, the token or path holds a character a cookie + /// can't (`;`, `,`, whitespace, quotes, a backslash or a control character), the domain isn't + /// a plain host name, or a `__Host-` cookie has a domain or a path other than `/`. + public static func setCookie(token: String) -> String? { + build(value: token, extra: nil) + } + + /// The `Set-Cookie` value that deletes the session cookie (on sign-out), with the same + /// attributes so the browser matches it. + public static func expiredCookie() -> String? { + build(value: "", extra: "Max-Age=0") + } + + static func build(value: String, extra: String?) -> String? { + let name = SessionConfig.name + let path = SessionConfig.cookiePath + let domain = SessionConfig.cookieDomain + guard isToken(name), value.unicodeScalars.allSatisfy(isCookieOctet), + !path.isEmpty, path.unicodeScalars.allSatisfy(isPathCharacter), + domain.isEmpty || isHostName(domain) else { return nil } + // Browsers recognize the prefixes ignoring case. + let lowerName = name.lowercased() + let hostPrefixed = lowerName.hasPrefix("__host-") + if hostPrefixed && (!domain.isEmpty || path != "/") { return nil } + let secure = SessionConfig.cookieSecure || SessionConfig.cookieSameSite == .none + || hostPrefixed || lowerName.hasPrefix("__secure-") + var parts = ["\(name)=\(value)", "Path=\(path)"] + if !domain.isEmpty { parts.append("Domain=\(domain)") } + if let extra { parts.append(extra) } + if SessionConfig.cookieHTTPOnly { parts.append("HttpOnly") } + if secure { parts.append("Secure") } + parts.append("SameSite=\(SessionConfig.cookieSameSite.rawValue)") + return parts.joined(separator: "; ") + } + + /// RFC 7230 `token`: visible ASCII except separators. + static func isToken(_ s: String) -> Bool { + !s.isEmpty && s.unicodeScalars.allSatisfy { c in + c.value > 0x20 && c.value < 0x7f && !"()<>@,;:\\\"/[]?={}".unicodeScalars.contains(c) + } + } + + /// RFC 6265 `cookie-octet`. + static func isCookieOctet(_ c: Unicode.Scalar) -> Bool { + c.value == 0x21 || (0x23...0x2b).contains(c.value) || (0x2d...0x3a).contains(c.value) + || (0x3c...0x5b).contains(c.value) || (0x5d...0x7e).contains(c.value) + } + + /// RFC 6265 `path-value` (any CHAR but controls and `;`), also without spaces or commas. + static func isPathCharacter(_ c: Unicode.Scalar) -> Bool { + c.value > 0x20 && c.value < 0x7f && c != ";" && c != "," + } + + static func isHostName(_ s: String) -> Bool { + s.unicodeScalars.allSatisfy { c in + ("a"..."z").contains(c) || ("A"..."Z").contains(c) || ("0"..."9").contains(c) || c == "-" || c == "." + } + } +} diff --git a/Sources/PerfectSessionCore/SessionDriver.swift b/Sources/PerfectSessionCore/SessionDriver.swift index ba68861..8f6f0dc 100644 --- a/Sources/PerfectSessionCore/SessionDriver.swift +++ b/Sources/PerfectSessionCore/SessionDriver.swift @@ -14,9 +14,31 @@ public protocol SessionDriver: Sendable { func destroy(token: String) async func clean() async func setup() async + /// Moves a session to a new token. See the default implementation. + func regenerate(_ session: PerfectSession) async -> PerfectSession } extension SessionDriver { public func clean() async {} public func setup() async {} + + /// Moves **session**'s user, data, creation time and idle time to a newly created session with a new token + /// and a new CSRF token, then destroys the old session. Call it whenever the user signs in, + /// signs out to a guest session, or gains privileges, and send the client the new token: + /// a token an attacker planted or saw beforehand (session fixation) then no longer works. + /// + /// The new session keeps the old one's IP address and user agent. If the backend can't store + /// it, the returned session isn't stored (as with `create`), and the old one is destroyed. + public func regenerate(_ session: PerfectSession) async -> PerfectSession { + var fresh = await create(ipaddress: session.ipaddress, useragent: session.useragent) + let csrf = fresh.data["csrf"] + fresh.userid = session.userid + fresh.created = session.created // keeps the absolute timeout running + fresh.idle = session.idle + fresh.data = session.data + fresh.data["csrf"] = csrf + await save(fresh) + await destroy(token: session.token) + return fresh + } } diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift index 6ebf75a..7c12daa 100644 --- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift +++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift @@ -19,10 +19,9 @@ public struct MongoDBSessionConnector: Sendable { /// data: { ... }, expiresAt: } /// /// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see -/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB +/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`, or `created + absoluteTimeout` if earlier), so MongoDB /// deletes expired sessions on its own; `clean()` still deletes them immediately for callers -/// that rely on it. Like the other drivers, `resume` returns expired sessions too: callers -/// check `PerfectSession.isValid()`. +/// that rely on it. Like the other drivers, `resume` refuses an expired session. /// /// `save` only updates an existing session, so a save racing a `destroy` (logout) can't bring /// the session back. `setup()` also deletes documents left by the archived Perfect-Session-MongoDB @@ -76,7 +75,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" @@ -85,7 +84,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { do { try await withCollection { try $0.insert(document) } } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } @@ -97,17 +96,23 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { try sessions.findOne(SessionDocument.self, filter: Self.filter(token: token)) } } catch { - logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) throw InvalidSessionError() } - guard var session = found?.session else { + // The TTL monitor only runs about once a minute, so check expiry here too. + guard var session = found?.session, !session.isExpired() else { throw InvalidSessionError() } session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one return session } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() let document = SessionDocument(session) do { let matched = try await withCollection { sessions in @@ -116,10 +121,10 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { if matched == 0 { // Expected after a logout when middleware saves at the end of the request. logger.debug("session save matched no session (destroyed or expired); not recreated", - metadata: ["eventid": "\(session.token)"]) + metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } @@ -127,18 +132,33 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable { do { _ = try await withCollection { try $0.deleteOne(filter: Self.filter(token: token)) } } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { let now = Date() + let cutoff = absoluteTimeout > 0 ? Int(now.timeIntervalSince1970) - absoluteTimeout : nil do { try await withCollection { sessions in let before = BSON() before.append(key: "$lt", dateTime: Self.millis(now)) - let filter = BSON() - filter.append(key: "expiresAt", document: before) + let expired = BSON() + expired.append(key: "expiresAt", document: before) + guard let cutoff else { + try sessions.deleteMany(filter: expired) + return + } + // Documents saved before absoluteTimeout was set can have a later expiresAt. + let filter = try BSON(json: """ + {"$or": [{"expiresAt": {"$lt": {"$date": {"$numberLong": "\(Self.millis(now))"}}}}, + {"created": {"$lte": \(cutoff)}}]} + """) try sessions.deleteMany(filter: filter) } } catch { @@ -204,7 +224,7 @@ struct SessionDocument: Codable, Sendable { var data: JSONValue var expiresAt: Date - init(_ session: PerfectSession) { + init(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout) { _id = session.token userid = session.userid created = session.created @@ -215,9 +235,15 @@ struct SessionDocument: Codable, Sendable { // Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber. data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:])) .mappingKeys(Self.escape) - expiresAt = Date(timeIntervalSince1970: TimeInterval(session.updated + session.idle)) + expiresAt = Date(timeIntervalSince1970: TimeInterval(min(max(session.expiry(absoluteTimeout: absoluteTimeout), + Self.earliestExpiry), Self.latestExpiry))) } + /// BSON dates are 64-bit milliseconds, and Perfect-MongoDB's encoder traps on a `Date` outside + /// that range, so `expiresAt` is clamped to years 0001 through 9999. + static let earliestExpiry = -62_135_596_800 + static let latestExpiry = 253_402_300_799 + var session: PerfectSession { var session = PerfectSession() session.token = _id diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift index 1acfb49..c0b6418 100644 --- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift +++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift @@ -13,7 +13,7 @@ public struct MySQLSessionConnector: Sendable { private init() {} } -public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { +public final class MySQLSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { @@ -21,9 +21,9 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { CREATE TABLE IF NOT EXISTS `\(MySQLSessionConnector.table)` ( `token` varchar(255) NOT NULL, `userid` varchar(255), - `created` int NOT NULL DEFAULT 0, - `updated` int NOT NULL DEFAULT 0, - `idle` int NOT NULL DEFAULT 0, + `created` bigint NOT NULL DEFAULT 0, + `updated` bigint NOT NULL DEFAULT 0, + `idle` bigint NOT NULL DEFAULT 0, `data` text, `ipaddress` varchar(255), `useragent` text, @@ -35,7 +35,7 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" @@ -62,23 +62,28 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { var s = PerfectSession() s.token = row[0] as? String ?? "" s.userid = row[1] as? String ?? "" - s.created = Int(row[2] as? Int32 ?? 0) - s.updated = Int(row[3] as? Int32 ?? 0) - s.idle = Int(row[4] as? Int32 ?? 0) + s.created = Self.int(row[2]) + s.updated = Self.int(row[3]) + s.idle = Self.int(row[4]) if let d = row[5] as? String { s.fromjson(d) } s.ipaddress = row[6] as? String ?? "" s.useragent = row[7] as? String ?? "" found = s } - guard var s = found else { throw InvalidSessionError() } + guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() exec( - "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,updated=?,idle=?,data=? WHERE token=?", - params: [session.userid, session.updated, session.idle, session.tojson(), session.token] + "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,created=?,updated=?,idle=?,data=? WHERE token=?", + params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token] ) } @@ -86,24 +91,53 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable { exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE token=?", params: [token]) } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { - exec( - "DELETE FROM `\(MySQLSessionConnector.table)` WHERE updated + idle < ?", - params: [Int(Date().timeIntervalSince1970)] - ) + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { + let now = Int(Date().timeIntervalSince1970) + // DECIMAL arithmetic: a BIGINT sum overflows (an error) for an idle near Int.max. + let idle = "CAST(updated AS DECIMAL(30,0)) + idle < ?" + if absoluteTimeout > 0 { + exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle) OR CAST(created AS DECIMAL(30,0)) + ? <= ?", + params: [now, absoluteTimeout, now]) + } else { + exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle)", params: [now]) + } } // MARK: - Private helpers + /// An integer column: Int64 from the BIGINT columns `setup()` creates, Int32 from the INT + /// columns of tables created before 4.0. + private static func int(_ value: Any?) -> Int { + switch value { + case let v as Int64: return Int(v) + case let v as Int32: return Int(v) + case let v as Int: return v + case let v as UInt64: return Int(exactly: v) ?? Int.max + case let v as UInt32: return Int(v) + case let v as String: return Int(v) ?? 0 + default: return 0 + } + } + private func connect() -> MySQL { let db = MySQL() if MySQLSessionConnector.socket.isEmpty { + // Not UInt32(_:), which traps on a negative port. Port 0 would mean the default + // (3306), so an out-of-range port leaves the connection unopened: every call fails. + guard let port = UInt32(exactly: MySQLSessionConnector.port), (1...65535).contains(port) else { + return db + } _ = db.connect( host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, - port: UInt32(MySQLSessionConnector.port) + port: port ) } else { _ = db.connect( diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift index cbf484a..b49feb2 100644 --- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift +++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift @@ -11,16 +11,29 @@ public struct PostgreSQLSessionConnector: Sendable { nonisolated(unsafe) public static var port: Int = 5432 private init() {} + /// A libpq connection URI for the connector's settings. public static func connectionString() -> String { - let u = username.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? username - let p = password.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? password - let h = host.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? host - let d = database.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? database - return "postgresql://\(u):\(p)@\(h):\(port)/\(d)" + connectionString(username: username, password: password, host: host, port: port, database: database) } + + /// A libpq connection URI. Each part is percent-encoded except RFC 3986's unreserved + /// characters, so a password containing `@`, `/`, `:`, `?` or `#` can't change the URI's shape. + /// The host may also be a bracketed IPv6 address, or a Unix-socket directory (encoded, as + /// libpq expects). + public static func connectionString(username: String, password: String, host: String, + port: Int, database: String) -> String { + func encode(_ part: String) -> String { + part.addingPercentEncoding(withAllowedCharacters: unreserved) ?? part + } + let h = host.addingPercentEncoding(withAllowedCharacters: unreserved.union(CharacterSet(charactersIn: "[]:"))) ?? host + return "postgresql://\(encode(username)):\(encode(password))@\(h):\(port)/\(encode(database))" + } + + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") } -public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { +public final class PostgreSQLSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { @@ -28,9 +41,9 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { CREATE TABLE IF NOT EXISTS "\(PostgreSQLSessionConnector.table)" ( "token" varchar NOT NULL, "userid" varchar, - "created" int4 NOT NULL DEFAULT 0, - "updated" int4 NOT NULL DEFAULT 0, - "idle" int4 NOT NULL DEFAULT 0, + "created" int8 NOT NULL DEFAULT 0, + "updated" int8 NOT NULL DEFAULT 0, + "idle" int8 NOT NULL DEFAULT 0, "data" text, "ipaddress" varchar, "useragent" text, @@ -42,7 +55,7 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" @@ -73,14 +86,20 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { if let d = result.getFieldString(tupleIndex: 0, fieldIndex: 5) { s.fromjson(d) } s.ipaddress = result.getFieldString(tupleIndex: 0, fieldIndex: 6) ?? "" s.useragent = result.getFieldString(tupleIndex: 0, fieldIndex: 7) ?? "" + guard !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() exec( - "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,updated=$2,idle=$3,data=$4 WHERE token=$5", - params: [session.userid, session.updated, session.idle, session.tojson(), session.token] + "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,created=$2,updated=$3,idle=$4,data=$5 WHERE token=$6", + params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token] ) } @@ -88,11 +107,21 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable { exec("DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE token=$1", params: [token]) } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { - exec( - "DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE updated + idle < $1", - params: [Int(Date().timeIntervalSince1970)] - ) + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { + let now = Int(Date().timeIntervalSince1970) + // numeric arithmetic: an integer sum overflows (an error) for an idle near the type's max. + let table = "\"\(PostgreSQLSessionConnector.table)\"" + if absoluteTimeout > 0 { + exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1 OR created::numeric + $2 <= $1", + params: [now, absoluteTimeout]) + } else { + exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1", params: [now]) + } } // MARK: - Private helpers diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift index 7123c16..4678df7 100644 --- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift +++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift @@ -5,87 +5,139 @@ import Logging private let logger = Logger(label: "perfect.session.redis") +/// Connection settings used by `RedisSessionDriver`. Set them once at startup. public struct RedisSessionConnector: Sendable { nonisolated(unsafe) public static var host: String = "127.0.0.1" nonisolated(unsafe) public static var password: String = "" nonisolated(unsafe) public static var port: Int = 6379 + /// Prepended to each token to make its Redis key. Tokens come from clients' cookies, so the + /// prefix keeps a token from naming one of the application's own keys. + nonisolated(unsafe) public static var keyPrefix: String = "perfect-session:" + /// Seconds each Redis call may take, 5 by default, or nil to wait indefinitely. A call that + /// runs out throws (the driver logs it, and `resume` throws `InvalidSessionError`), and its + /// connection is closed; see Perfect-Redis's `RedisClientIdentifier.commandTimeout`. It also + /// bounds connecting, after the host name lookup. + nonisolated(unsafe) public static var commandTimeout: Double? = 5 private init() {} } -public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { +/// Stores each session as a JSON string under `keyPrefix + token`, with a TTL of the session's +/// `idle` seconds, renewed by every `save`. Each call opens its own connection. +public final class RedisSessionDriver: SessionDriver, Sendable { public init() {} public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" session.setCSRF() + guard session.idle > 0 else { + logger.error("session not stored: idle must be positive, not \(session.idle)") + return session + } guard let json = encode(session) else { return session } do { - let client = try await connect() - _ = try await client.set( - key: session.token, value: .string(json), - expires: Double(SessionConfig.idle) - ) - try? await client.close() + _ = try await withClient { client in + try await client.set(key: self.key(session.token), value: .string(json), + expires: Self.ttl(session), ifNotExists: true) + } } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } public func resume(token: String) async throws -> PerfectSession { - let client = try await connect() + let response: RedisResponse do { - let response = try await client.get(key: token) - try? await client.close() - guard let json = response.string, !json.isEmpty else { throw InvalidSessionError() } - guard var session = decode(token: token, json: json) else { throw InvalidSessionError() } - session._state = "resume" - return session - } catch let e as InvalidSessionError { - throw e + response = try await withClient { try await $0.get(key: self.key(token)) } } catch { - try? await client.close() + logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) throw InvalidSessionError() } + guard let json = response.string, !json.isEmpty, + var session = decode(token: token, json: json), !session.isExpired() else { + throw InvalidSessionError() + } + session._state = "resume" + session.setCSRF() // a stored session without a CSRF token gets one + return session } + /// Updates a stored session and renews its TTL. A session that is no longer stored (it was + /// destroyed or has expired) isn't stored again, so a save racing a logout can't bring the + /// session back. A session whose `idle` is 0 or less is deleted. public func save(_ session: PerfectSession) async { var s = session s.touch() - guard let json = encode(s) else { return } do { - let client = try await connect() - _ = try await client.set( - key: s.token, value: .string(json), - expires: Double(SessionConfig.idle) - ) - try? await client.close() + guard !s.isExpired() else { // idle of 0 or less, or past absoluteTimeout + _ = try await withClient { try await $0.delete(keys: self.key(s.token)) } + return + } + guard let json = encode(s) else { return } + let token = s.token, ttl = Self.ttl(s) + let reply = try await withClient { client in + try await client.set(key: self.key(token), value: .string(json), + expires: ttl, ifExists: true) + } + if reply.isNil { + logger.debug("session save matched no session (destroyed or expired); not recreated", + metadata: ["session": "\(PerfectSession.logID(s.token))"]) + } } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(s.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(s.token))"]) } } public func destroy(token: String) async { do { - let client = try await connect() - try await client.delete(keys: token) - try? await client.close() + _ = try await withClient { try await $0.delete(keys: self.key(token)) } } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } // MARK: - Private helpers + /// The longest TTL set, in seconds (about 31,700 years). PX takes milliseconds, which for an + /// idle near Int.max neither Perfect-Redis (Int milliseconds) nor Redis (now + PX must fit + /// in 64 bits) accepts; the session's own `idle` is still stored as is. + static let maximumTTL = 1_000_000_000_000 + + /// The key's TTL in seconds: until the session's `expiry(absoluteTimeout:)`, at most + /// `maximumTTL`, at least 1 (save() deletes a session already past it). + static func ttl(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout, + now: Int = Int(Date().timeIntervalSince1970)) -> Double { + let (remaining, overflow) = session.expiry(absoluteTimeout: absoluteTimeout).subtractingReportingOverflow(now) + return Double(max(1, min(overflow ? maximumTTL : remaining, maximumTTL))) + } + + private func key(_ token: String) -> String { + RedisSessionConnector.keyPrefix + token + } + + /// Runs **body** on a new connection, then closes it. + private func withClient(_ body: (RedisClient) async throws -> T) async throws -> T { + let client = try await connect() + do { + let result = try await body(client) + try? await client.close() + return result + } catch { + try? await client.close() + throw error + } + } + private func connect() async throws -> RedisClient { let id = RedisClientIdentifier( withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, - password: RedisSessionConnector.password + password: RedisSessionConnector.password, + commandTimeout: RedisSessionConnector.commandTimeout ) return try await RedisClient.connect(withIdentifier: id) } @@ -98,9 +150,10 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable { "idle": session.idle, "ipaddress": session.ipaddress, "useragent": session.useragent, - "data": session.data, + "data": session.jsonSafeData(), ] - guard let d = try? JSONSerialization.data(withJSONObject: dict), + guard JSONSerialization.isValidJSONObject(dict), + let d = try? JSONSerialization.data(withJSONObject: dict), let s = String(data: d, encoding: .utf8) else { return nil } return s } diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift index 2425f4f..5ccb4e7 100644 --- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift +++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift @@ -11,7 +11,7 @@ public struct SQLiteSessionConnector: Sendable { private init() {} } -public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { +public final class SQLiteSessionDriver: SessionDriver, Sendable { public init() {} public func setup() async { @@ -38,7 +38,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession { var session = PerfectSession() - session.token = UUID().uuidString + session.token = PerfectSession.newToken() session.ipaddress = ipaddress session.useragent = useragent session._state = "new" @@ -60,7 +60,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { } ) } catch { - logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } return session } @@ -85,17 +85,22 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { found = s } ) - guard var s = found else { throw InvalidSessionError() } + guard var s = found, !s.isExpired() else { throw InvalidSessionError() } s._state = "resume" + s.setCSRF() // a stored session without a CSRF token gets one return s } + /// Updates a stored session and renews its idle time (`touch()`). A session that isn't + /// stored (destroyed or expired) isn't stored again. public func save(_ session: PerfectSession) async { + var session = session + session.touch() do { let db = try SQLite(SQLiteSessionConnector.databasePath) defer { db.close() } try db.execute( - statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=? WHERE token=?", + statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=?,created=? WHERE token=?", doBindings: { stmt in try stmt.bind(position: 1, session.userid) try stmt.bind(position: 2, session.updated) @@ -103,11 +108,12 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { try stmt.bind(position: 4, session.tojson()) try stmt.bind(position: 5, session.ipaddress) try stmt.bind(position: 6, session.useragent) - try stmt.bind(position: 7, session.token) + try stmt.bind(position: 7, session.created) + try stmt.bind(position: 8, session.token) } ) } catch { - logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"]) + logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"]) } } @@ -120,18 +126,29 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable { doBindings: { stmt in try stmt.bind(position: 1, token) } ) } catch { - logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"]) + logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"]) } } + /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`. public func clean() async { + await clean(absoluteTimeout: SessionConfig.absoluteTimeout) + } + + func clean(absoluteTimeout: Int) async { do { let db = try SQLite(SQLiteSessionConnector.databasePath) defer { db.close() } let now = Int(Date().timeIntervalSince1970) + // A positive absolute timeout also deletes sessions created at or before now - timeout + // (SQLite turns an overflowing integer sum into a real, so neither side overflows). + let cutoff = absoluteTimeout > 0 ? now - absoluteTimeout : Int.min try db.execute( - statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ?", - doBindings: { stmt in try stmt.bind(position: 1, now) } + statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ? OR created <= ?", + doBindings: { stmt in + try stmt.bind(position: 1, now) + try stmt.bind(position: 2, cutoff) + } ) } catch { logger.error("session clean failed: \(error)") diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift index 0683f2f..5e82208 100644 --- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift +++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift @@ -1,6 +1,21 @@ import Testing import Foundation @testable import PerfectSessionCore +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +/// Whether the ThreadSanitizer runtime is loaded. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() @Suite struct PerfectSessionCoreTests { @@ -32,6 +47,38 @@ import Foundation #expect(s.tojson() == "{}") } + /// JSONSerialization raises an Objective-C exception (on Darwin), which Swift can't catch, + /// for values JSON can't hold; tojson() used to crash the process on them. + @Test(arguments: ["nan", "infinity", "date", "url", "intKey", "nestedDate"]) + func sessionToJsonNonJSONValueDoesNotCrash(_ kind: String) { + var s = PerfectSession() + s.data["ok"] = "kept" + s.data["csrf"] = "csrf-token" + switch kind { + case "nan": s.data["x"] = Double.nan + case "infinity": s.data["x"] = Double.infinity + case "date": s.data["x"] = Date() + case "url": s.data["x"] = URL(string: "https://example.com")! + case "intKey": s.data["x"] = [1: "one"] + default: s.data["x"] = ["when": [Date()]] + } + // Only the bad key goes; the rest of the data and the CSRF token stay. + var restored = PerfectSession() + restored.fromjson(s.tojson()) + #expect(restored.data["ok"] as? String == "kept") + #expect(restored.data["csrf"] as? String == "csrf-token") + #expect(restored.data["x"] == nil) + } + + /// A CSRF token JSON can't hold is replaced, never dropped. + @Test func sessionToJsonReplacesBadCSRF() { + var s = PerfectSession() + s.data["csrf"] = Date() + var restored = PerfectSession() + restored.fromjson(s.tojson()) + #expect((restored.data["csrf"] as? String)?.count == 64) + } + @Test func sessionFromJsonBadInput() { var s = PerfectSession() s.data["key"] = "before" @@ -73,16 +120,56 @@ import Foundation #expect(s.isValid()) } + /// `updated + idle` trapped on overflow, so an idle of Int.max ("never expire") crashed + /// isValid() and MemorySessionDriver.clean(). + @Test func hugeIdleDoesNotTrap() async throws { + var s = PerfectSession() + s.idle = Int.max + #expect(s.isValid()) + #expect(s.expires == Int.max) + s.idle = Int.min + #expect(!s.isValid()) + + let driver = MemorySessionDriver() + var stored = await driver.create() + stored.idle = Int.max + await driver.save(stored) + await driver.clean() + _ = try await driver.resume(token: stored.token) + } + + @Test func absoluteTimeout() { + var s = PerfectSession() + let now = Int(Date().timeIntervalSince1970) + s.created = now - 100 + s.updated = now + s.idle = 3600 + #expect(!s.isExpired(at: now, absoluteTimeout: 0)) + #expect(!s.isExpired(at: now, absoluteTimeout: 101)) + #expect(s.isExpired(at: now, absoluteTimeout: 100)) + #expect(!s.isExpired(at: now, absoluteTimeout: Int.max)) + #expect(s.isExpired(at: now + 3600, absoluteTimeout: 0)) + } + + /// resume() used to return an expired session, leaving the check to the caller. + @Test func memoryResumeRejectsExpired() async { + let driver = MemorySessionDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + @Test func sessionIsValidIPLock() { var s = PerfectSession() s.idle = 86400 s.updated = Int(Date().timeIntervalSince1970) s.ipaddress = "10.0.0.1" - let prev = SessionConfig.IPAddressLock - SessionConfig.IPAddressLock = true - defer { SessionConfig.IPAddressLock = prev } - #expect(s.isValid(ipaddress: "10.0.0.1")) - #expect(!s.isValid(ipaddress: "10.0.0.2")) + #expect(s.isValid(ipaddress: "10.0.0.1", ipAddressLock: true)) + #expect(!s.isValid(ipaddress: "10.0.0.2", ipAddressLock: true)) + #expect(s.isValid(ipaddress: "10.0.0.2", ipAddressLock: false)) } @Test func sessionIsValidUALock() { @@ -90,11 +177,59 @@ import Foundation s.idle = 86400 s.updated = Int(Date().timeIntervalSince1970) s.useragent = "Safari/17" - let prev = SessionConfig.userAgentLock - SessionConfig.userAgentLock = true - defer { SessionConfig.userAgentLock = prev } - #expect(s.isValid(useragent: "Safari/17")) - #expect(!s.isValid(useragent: "Chrome/120")) + #expect(s.isValid(useragent: "Safari/17", userAgentLock: true)) + #expect(!s.isValid(useragent: "Chrome/120", userAgentLock: true)) + #expect(s.isValid(useragent: "Chrome/120", userAgentLock: false)) + } + + // MARK: - Tokens + + /// logID is the first 12 hex digits of the token's SHA-256. + @Test func logIDIsSHA256Prefix() { + #expect(PerfectSession.sha256Hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + #expect(PerfectSession.sha256Hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + #expect(PerfectSession.sha256Hex(String(repeating: "a", count: 1000)) == + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3") + #expect(PerfectSession.logID("abc") == "ba7816bf8f01") + } + + @Test func tokensAre256BitHex() async { + let driver = MemorySessionDriver() + var seen = Set() + for _ in 0..<500 { + let session = await driver.create() + for token in [session.token, session.data["csrf"] as? String ?? ""] { + #expect(token.count == 64) + #expect(token.allSatisfy { "0123456789abcdef".contains($0) }) + seen.insert(token) + } + } + #expect(seen.count == 1000) + } + + /// On Linux, Foundation's UUID() reads /dev/urandom and ignores a failed open(), so with no + /// file descriptor left every UUID was 00000000-0000-4000-8000-000000000000: every new + /// session got the same token. Runs in a child process, which it starves of descriptors. + /// (Skipped under ThreadSanitizer: exit tests abort under it, even one that only calls exit(0).) + @Test(.disabled(if: underThreadSanitizer, "ThreadSanitizer needs file descriptors")) + func tokensStayRandomWithoutFileDescriptors() async { + await #expect(processExitsWith: .success) { + #if os(Linux) + let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue) + #else + let resource = RLIMIT_NOFILE + #endif + var limit = rlimit() + getrlimit(resource, &limit) + limit.rlim_cur = 64 + setrlimit(resource, &limit) + while open("/dev/null", O_RDONLY) >= 0 {} + let driver = MemorySessionDriver() + let first = await driver.create(), second = await driver.create() + let distinct = first.token != second.token + && first.data["csrf"] as? String != second.data["csrf"] as? String + exit(distinct ? EXIT_SUCCESS : EXIT_FAILURE) + } } // MARK: - MemorySessionDriver @@ -143,12 +278,159 @@ import Foundation } } + /// save() used to insert, so a save racing a logout brought the session back, and saving a + /// session built around a client-chosen token stored it (session fixation). + @Test func memorySaveOnlyUpdatesStoredSessions() async { + let driver = MemorySessionDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + + var planted = PerfectSession() + planted.token = "attacker-chosen" + await driver.save(planted) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: "attacker-chosen") + } + } + + /// There was no way to move a session to a new token, so a token fixed before sign-in + /// stayed valid after it. + @Test func memoryRegenerate() async throws { + let driver = MemorySessionDriver() + var session = await driver.create(ipaddress: "10.0.0.9", useragent: "UA") + session.userid = "alice" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + #expect(fresh.data["csrf"] as? String != session.data["csrf"] as? String) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "alice") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.ipaddress == "10.0.0.9") + #expect(resumed.useragent == "UA") + } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = MemorySessionDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + // The memory driver keeps values as they are, so "when" survives here. + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = MemorySessionDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + @Test func memoryCleanHonoursAbsoluteTimeout() async throws { + let driver = MemorySessionDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: old.token) + } + _ = try await driver.resume(token: young.token) + } + + @Test func expiryTakesTheEarlierLimit() { + var s = PerfectSession() + s.created = 1000 + s.updated = 5000 + s.idle = 100 + #expect(s.expiry(absoluteTimeout: 0) == 5100) + #expect(s.expiry(absoluteTimeout: 3000) == 4000) + #expect(s.expiry(absoluteTimeout: 10000) == 5100) + #expect(s.expiry(absoluteTimeout: Int.max) == 5100) + } + @Test func memoryClean() async throws { let driver = MemorySessionDriver() var session = await driver.create() // expire: set updated far in the past so clean() will purge it - session.idle = 1 - session.updated = 0 // epoch — definitely expired + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) await driver.clean() await #expect(throws: InvalidSessionError.self) { @@ -158,29 +440,91 @@ import Foundation // MARK: - AuthFilter (uses local config to avoid parallel-test races on global state) + func requiresAuth(_ path: String, _ config: AuthenticationConfig) -> Bool { + AuthFilter.requiresAuthentication(path, using: config) + } + @Test func authFilterInclusion() { var config = AuthenticationConfig() config.include("/admin") config.include("/profile") - #expect(AuthFilter.shouldWeAccept("/admin", using: config)) - #expect(AuthFilter.shouldWeAccept("/profile", using: config)) - #expect(!AuthFilter.shouldWeAccept("/public", using: config)) + #expect(requiresAuth("/admin", config)) + #expect(requiresAuth("/profile", config)) + #expect(!requiresAuth("/public", config)) } @Test func authFilterWildcard() { var config = AuthenticationConfig() config.include("/admin/*") - #expect(AuthFilter.shouldWeAccept("/admin/users", using: config)) - #expect(AuthFilter.shouldWeAccept("/admin/settings", using: config)) - #expect(!AuthFilter.shouldWeAccept("/public/page", using: config)) + #expect(requiresAuth("/admin/users", config)) + #expect(requiresAuth("/admin/settings", config)) + #expect(requiresAuth("/admin", config)) + #expect(!requiresAuth("/public/page", config)) } @Test func authFilterExclusion() { var config = AuthenticationConfig() config.include("*") config.exclude("/healthcheck") - #expect(AuthFilter.shouldWeAccept("/anything", using: config)) - #expect(!AuthFilter.shouldWeAccept("/healthcheck", using: config)) + #expect(requiresAuth("/anything", config)) + #expect(!requiresAuth("/healthcheck", config)) + } + + /// Perfect-NIO 0.1.0 routes /ADMIN and /Admin/users to the /admin handlers, and the old + /// string matching said they needed no authentication. + @Test(arguments: ["/admin", "/Admin", "/ADMIN", "/admin/", "//admin", "/./admin", "/Admin/users", + "/ADMIN/USERS", "/admin/users/", "/admin//users", "/admin/./users", + "/%61dmin", "/admin;x=1", "/x/../admin", "/admin\\users", "admin"]) + func authFilterNormalizesPaths(_ path: String) { + var config = AuthenticationConfig() + config.include("/admin") + #expect(requiresAuth(path, config), "\(path)") + } + + /// A pattern covers its own path and everything below it, segment by segment. + @Test func authFilterMatchesWholeSegments() { + var config = AuthenticationConfig() + config.include("/admin") + #expect(!requiresAuth("/administrator", config)) + #expect(!requiresAuth("/adm", config)) + #expect(!requiresAuth("/", config)) + config = AuthenticationConfig() + config.include("/api/*") + config.exclude("/api/public") + #expect(requiresAuth("/api/x", config)) + #expect(!requiresAuth("/api/public", config)) + #expect(!requiresAuth("/API/Public/docs", config)) + #expect(!requiresAuth("/apis", config)) + } + + /// `*` is only a wildcard as a whole final segment. An inclusion that uses it any other + /// way ("*.json", "/admin*") requires authentication everywhere rather than silently + /// protecting nothing; such an exclusion matches nothing. + @Test func authFilterRejectsPartialWildcards() { + var config = AuthenticationConfig() + config.include("/admin*") + #expect(requiresAuth("/administrator", config)) + #expect(requiresAuth("/public", config)) + config = AuthenticationConfig() + config.include("*") + config.exclude("*.json") + #expect(requiresAuth("/x.json", config)) + } + + /// Case-folding can be turned off for servers that route case-sensitively. + @Test func authFilterCaseSensitiveOption() { + var config = AuthenticationConfig() + config.include("/admin") + config.caseInsensitive = false + #expect(requiresAuth("/admin/x", config)) + #expect(!requiresAuth("/Admin", config)) + } + + /// With nothing protected, odd characters don't start requiring authentication. + @Test func authFilterNoInclusionsNeverRequiresAuth() { + let config = AuthenticationConfig() + #expect(!requiresAuth("/caf%C3%A9", config)) + #expect(!requiresAuth("/../x", config)) } // MARK: - CSRFSecurity @@ -198,15 +542,69 @@ import Foundation } @Test func csrfSecurityStripsScheme() { - #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "http://example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "https://example.com")) + } + + /// The check ignored the scheme (an http origin passed for an https host), treated + /// example.com:443 and example.com as different, and compared the Host header's case. + @Test func csrfSecurityComparesSchemePortAndCase() { + #expect(!CSRFSecurity.isValid(origin: "http://example.com", host: "https://example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:443")) + #expect(CSRFSecurity.isValid(origin: "https://example.com:443", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "http://example.com", host: "example.com:80")) + #expect(!CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:80")) + #expect(!CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com")) + #expect(CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com:8443")) + #expect(CSRFSecurity.isValid(origin: "https://Example.COM", host: "EXAMPLE.com")) + #expect(!CSRFSecurity.isValid(origin: "null", host: "null")) + #expect(CSRFSecurity.isValid(origin: "https://trusted.com:443", host: "other.com", + acceptableHostnames: ["https://Trusted.com"])) + #expect(!CSRFSecurity.isValid(origin: "http://trusted.com", host: "other.com", + acceptableHostnames: ["https://trusted.com"])) } @Test func csrfSecurityAcceptableHostnames() { - let prev = SessionConfig.CSRF.acceptableHostnames - SessionConfig.CSRF.acceptableHostnames = ["trusted.com"] - defer { SessionConfig.CSRF.acceptableHostnames = prev } - #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com")) - #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com")) + #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com", acceptableHostnames: ["trusted.com"])) + #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com", acceptableHostnames: ["trusted.com"])) + } + + @Test func csrfTokenMatches() { + let token = PerfectSession.newToken() + #expect(CSRFSecurity.tokenMatches(token, token)) + #expect(!CSRFSecurity.tokenMatches(token.uppercased(), token)) + #expect(!CSRFSecurity.tokenMatches(String(token.dropLast()), token)) + #expect(!CSRFSecurity.tokenMatches(token + "0", token)) + #expect(!CSRFSecurity.tokenMatches("", "")) + #expect(!CSRFSecurity.tokenMatches("", token)) + } + + // MARK: - CORSSecurity + + /// A wildcard pattern used to accept any origin that matched its prefix OR its suffix, so + /// "https://*.example.com" accepted every https origin. + @Test func corsWildcardNeedsPrefixAndSuffix() { + var config = SessionConfig.CORSconfig() + config.enabled = true + config.acceptableHostnames = ["https://*.example.com", "http://localhost:*"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config)) + #expect(CORSSecurity.isAcceptable(origin: "https://A.Example.com", config: config)) + #expect(CORSSecurity.isAcceptable(origin: "http://localhost:8080", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://evil.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.com.evil.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "http://app.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://localhost:8080", config: config)) + + config.acceptableHostnames = ["*.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://example.org", config: config)) + + config.acceptableHostnames = ["https://Exact.example.com"] + #expect(CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config)) + #expect(!CORSSecurity.isAcceptable(origin: "https://other.example.com", config: config)) + + config.enabled = false + #expect(!CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config)) } // MARK: - CookieSameSite @@ -217,3 +615,76 @@ import Foundation #expect(CookieSameSite.none.rawValue == "None") } } + +/// Serialized because each test changes SessionConfig's cookie settings, which no other test reads. +@Suite(.serialized) struct SessionCookieTests { + func withCookieConfig(name: String = "PerfectSession", domain: String = "", path: String = "/", + secure: Bool = false, httpOnly: Bool = true, sameSite: CookieSameSite = .strict, + _ body: () -> Void) { + let saved = (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) + defer { + (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) = saved + } + (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath, + SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) = + (name, domain, path, secure, httpOnly, sameSite) + body() + } + + @Test func defaults() { + withCookieConfig { + #expect(SessionCookie.setCookie(token: "abc123") == "PerfectSession=abc123; Path=/; HttpOnly; SameSite=Strict") + #expect(SessionCookie.expiredCookie() == "PerfectSession=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict") + } + } + + @Test func allAttributes() { + withCookieConfig(domain: "example.com", path: "/app", secure: true, httpOnly: false, sameSite: .lax) { + #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/app; Domain=example.com; Secure; SameSite=Lax") + } + } + + @Test func sameSiteNoneAndPrefixesForceSecure() { + withCookieConfig(sameSite: .none) { + #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/; HttpOnly; Secure; SameSite=None") + } + withCookieConfig(name: "__Host-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__Host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } + withCookieConfig(name: "__Host-sid", domain: "example.com") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + withCookieConfig(name: "__Host-sid", path: "/app") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + // Browsers match the prefixes ignoring case. + withCookieConfig(name: "__HOST-sid", domain: "example.com") { + #expect(SessionCookie.setCookie(token: "t") == nil) + } + withCookieConfig(name: "__host-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } + withCookieConfig(name: "__SECURE-sid") { + #expect(SessionCookie.setCookie(token: "t") == "__SECURE-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict") + } + } + + /// Settings or a token containing ";" or CR/LF would otherwise add attributes or headers. + @Test func refusesValuesThatWouldInjectAttributes() { + withCookieConfig { + for token in ["a;Domain=evil.com", "a\r\nSet-Cookie: x=1", "a b", "a,b", "\"a\"", "é"] { + #expect(SessionCookie.setCookie(token: token) == nil, "\(token.debugDescription)") + } + } + withCookieConfig(name: "sid;Secure") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(name: "") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(domain: "example.com; SameSite=None") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(path: "/;HttpOnly") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig(path: "/a\nb") { #expect(SessionCookie.setCookie(token: "t") == nil) } + withCookieConfig { + #expect(SessionCookie.setCookie(token: PerfectSession.newToken()) != nil) + } + } +} diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift index 85c7bd0..3e9a09e 100644 --- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift +++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift @@ -1,18 +1,17 @@ import Testing import Foundation +import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionMongoDB import PerfectSessionCore import PerfectMongoDB -/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test -@Suite(.serialized) struct PerfectSessionMongoDBTests { - - static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1" - static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost" - - func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver { - try MongoDBSessionDriver(uri: Self.uri, database: "perfect_sessions_test", collection: collection) - } +/// SessionDocument encoding; no server needed. +@Suite struct MongoDBSessionDocumentTests { @Test func documentRoundTripKeepsDataTypes() throws { var session = PerfectSession() @@ -34,6 +33,18 @@ import PerfectMongoDB #expect(restored.data["name"] as? String == "x") } + /// `updated + idle` trapped on overflow when building the document, and a far-future + /// `expiresAt` trapped in the BSON encoder. + @Test(arguments: [Int.max, Int.max / 2, 1 << 50, Int.min, -(1 << 50)]) + func hugeIdleDoesNotTrap(_ idle: Int) throws { + var session = PerfectSession() + session.idle = idle + let document = SessionDocument(session) + let seconds = document.expiresAt.timeIntervalSince1970 + #expect(seconds <= 253_402_300_799 && seconds >= -62_135_596_800) + _ = try BSONEncoder().encode(document) + } + @Test func keysBSONCantHoldAreEscapedAndRestored() throws { let awkward = ["", "%", "%%", "50%", "%25", "%00", "a%b", "nul\u{0}x", "\u{0}", "plain", "a.b", "$x"] for key in awkward { @@ -68,9 +79,22 @@ import PerfectMongoDB #expect((nested?["in\u{0}ner"] as? [String: Any])?["%"] as? Int == 3) #expect(((restored.data["list"] as? [Any])?.first as? [String: Any])?[""] as? Int == 4) } +} + +/// Live-test settings, outside the suite so its traits can refer to them. +enum MongoDBLive { + static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1" + static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost" +} + +/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test +@Suite(.serialized, .enabled(if: MongoDBLive.mongoEnabled, "set MONGODB_TESTS=1 to run against a server")) struct PerfectSessionMongoDBTests { + + func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver { + try MongoDBSessionDriver(uri: MongoDBLive.uri, database: "perfect_sessions_test", collection: collection) + } @Test func emptyKeySavesAndResumes() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.data[""] = "empty" @@ -83,7 +107,6 @@ import PerfectMongoDB } @Test func saveAfterDestroyDoesNotResurrect() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.userid = "logged-out" @@ -95,7 +118,6 @@ import PerfectMongoDB } @Test func setupPurgesLegacySessionsOnly() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver(collection: "sessions_legacy_test") func insertLegacy(_ token: String) async throws { // The shape Perfect-Session-MongoDB wrote: its own _id, the token in a field, data as JSON text. @@ -145,7 +167,6 @@ import PerfectMongoDB } @Test func createAndResume() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "MongoTest/1.0") #expect(!session.token.isEmpty) @@ -159,7 +180,6 @@ import PerfectMongoDB } @Test func resumeMissing() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -167,7 +187,6 @@ import PerfectMongoDB } @Test func saveAndResume() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.userid = "mongo-user-1" @@ -181,8 +200,20 @@ import PerfectMongoDB await driver.destroy(token: session.token) } + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = try getDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + await driver.destroy(token: session.token) + } + @Test func destroyRemovesSession() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -192,7 +223,6 @@ import PerfectMongoDB } @Test func dataIsStoredAsSubdocument() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver() var session = await driver.create() session.data["role"] = "admin" @@ -207,7 +237,6 @@ import PerfectMongoDB } @Test func setupCreatesTTLIndexAndCleanRemovesExpired() async throws { - guard Self.mongoEnabled else { return } let driver = try getDriver(collection: "sessions_ttl_test") try await driver.pool.withClient { client in _ = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test").drop() @@ -222,15 +251,242 @@ import PerfectMongoDB #expect(hasTTL) var expired = await driver.create() - expired.updated = Int(Date().timeIntervalSince1970) - 10 - expired.idle = 1 + expired.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(expired) let live = await driver.create() - await driver.clean() - await #expect(throws: InvalidSessionError.self) { - _ = try await driver.resume(token: expired.token) + // Counted directly: resume() refuses an expired session whether or not clean() deleted it. + let token = expired.token + func stored() async throws -> Int { + try await driver.pool.withClient { client in + try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test") + .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#)) + } } + #expect(try await stored() == 1) + await driver.clean() + #expect(try await stored() == 0) _ = try await driver.resume(token: live.token) await driver.destroy(token: live.token) } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = try getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = try getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = try getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = try getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = try getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + /// clean() and the TTL index used to ignore SessionConfig.absoluteTimeout. + @Test func cleanAndTTLHonourAbsoluteTimeout() async throws { + let driver = try getDriver() + var old = await driver.create() + old.created -= 1000 + #expect(SessionDocument(old, absoluteTimeout: 500).expiresAt == + Date(timeIntervalSince1970: TimeInterval(old.created + 500))) + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + func stored(_ token: String) async throws -> Int { + try await driver.pool.withClient { client in + try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_test") + .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#)) + } + } + #expect(try await stored(old.token) == 0) + #expect(try await stored(young.token) == 1) + await driver.destroy(token: young.token) + } + + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = try getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + + /// Keys MongoDB treats specially in queries and updates are stored as data, at any depth. + @Test func operatorAndDottedKeysRoundTrip() async throws { + let driver = try getDriver() + var session = await driver.create() + let keys = ["$where", "$set", "$", "a.b", ".", "$x.y", "_id", "expiresAt"] + for key in keys { session.data[key] = key } + session.data["nested"] = ["$gt": 1, "c.d": ["$ne": "x"]] + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + for key in keys { #expect(resumed.data[key] as? String == key, "\(key)") } + let nested = resumed.data["nested"] as? [String: Any] + #expect(nested?["$gt"] as? Int == 1) + #expect((nested?["c.d"] as? [String: Any])?["$ne"] as? String == "x") + await driver.destroy(token: session.token) + } + + /// The token is matched as a string, so one that looks like an operator matches nothing. + @Test(arguments: [#"{"$ne": ""}"#, "$ne", #"{"$gt": ""}"#, ".*", "\u{0}"]) + func operatorLikeTokensMatchNothing(_ token: String) async throws { + let driver = try getDriver() + let other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + _ = try await driver.resume(token: other.token) + await driver.destroy(token: other.token) + } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async throws { + let driver = try getDriver() + let reachable = try await driver.pool.withClient { client in + if case .replyDoc = client.serverStatus() { return true } + return false + } + #expect(reachable, "can't reach MongoDB at \(MongoDBLive.uri)") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MONGODB_TESTS fails rather than passing with +/// the live suite skipped. +@Test func mongoDBLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || MongoDBLive.mongoEnabled, "SESSION_REQUIRE_LIVE=1 but MONGODB_TESTS is not 1") } + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } +} + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func mongodbLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + + let driver = try! MongoDBSessionDriver(uri: "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=200&connectTimeoutMS=200") + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift index 7c60d52..1a35af6 100644 --- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift +++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift @@ -2,25 +2,54 @@ import Testing import Foundation @testable import PerfectSessionMySQL import PerfectSessionCore +import PerfectMySQL + +/// Live-test settings, outside the suite so its traits can refer to them. +enum MySQLLive { + /// MYSQL_PORT is required too, so the tests never fall back to a developer's own server on 3306. + static let port = Int(ProcessInfo.processInfo.environment["MYSQL_PORT"] ?? "") + static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" && port != nil +} // Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionMySQLTests { +@Suite(.serialized, .enabled(if: MySQLLive.mysqlEnabled, "set MYSQL_TESTS=1 and MYSQL_PORT to run against a server")) struct PerfectSessionMySQLTests { + + /// Drops the test table once per run, so setup() creates it with the current schema. + static let freshTable: Void = { + let db = MySQL() + if db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)) { + _ = db.query(statement: "DROP TABLE IF EXISTS `test_sessions`") + } + }() - static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) -> Bool { + let db = MySQL() + guard db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)), + db.query(statement: "SELECT 1 FROM `test_sessions` WHERE token = '\(token)'"), + let results = db.storeResults() else { return false } + return results.numRows() > 0 + } func getDriver() async -> MySQLSessionDriver { MySQLSessionConnector.host = ProcessInfo.processInfo.environment["MYSQL_HOST"] ?? "localhost" MySQLSessionConnector.username = ProcessInfo.processInfo.environment["MYSQL_USER"] ?? "root" MySQLSessionConnector.password = ProcessInfo.processInfo.environment["MYSQL_PASSWORD"] ?? "" MySQLSessionConnector.database = ProcessInfo.processInfo.environment["MYSQL_DATABASE"] ?? "perfect_sessions_test" + MySQLSessionConnector.port = MySQLLive.port ?? 0 MySQLSessionConnector.table = "test_sessions" + Self.freshTable let driver = MySQLSessionDriver() await driver.setup() return driver } @Test func createAndResume() async throws { - guard Self.mysqlEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "TestAgent/1.0") #expect(!session.token.isEmpty) @@ -33,7 +62,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -41,7 +69,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.mysqlEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "mysql-user-1" @@ -54,7 +81,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -63,16 +89,234 @@ import PerfectSessionCore } } + @Test func saveAfterDestroyDoesNotResurrect() async { + let driver = await getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// The columns were 32-bit: an idle time past Int32.max failed to store, and timestamps + /// stop at 2038. + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 3_000_000_000 + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.idle == 3_000_000_000) + #expect(resumed.created == session.created) + #expect(resumed.updated >= session.updated) // save() renews it + await driver.clean() + _ = try await driver.resume(token: session.token) + + // clean() still deletes expired sessions when another's updated + idle overflows BIGINT. + session.idle = Int.max + await driver.save(session) + var expired = await driver.create() + expired.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(expired) + await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + #expect(!isStored(expired.token)) + await driver.destroy(token: session.token) + } + + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + await driver.destroy(token: session.token) + } + @Test func clean() async { - guard Self.mysqlEnabled else { return } let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) + #expect(isStored(session.token)) await driver.clean() + #expect(!(isStored(session.token))) + } + + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(isStored(old.token))) + #expect(isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(isStored(young.token)) + await driver.destroy(token: young.token) + } + + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) } + + /// UInt32(port) trapped on a negative or too-large port. + @Test(arguments: [-1, Int(UInt32.max) + 1, 70_000]) + func invalidPortDoesNotTrap(_ port: Int) async { + let driver = await getDriver() + MySQLSessionConnector.port = port + defer { MySQLSessionConnector.port = MySQLLive.port ?? 0 } + let session = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async { + _ = await getDriver() + let db = MySQL() + let connected = db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username, + password: MySQLSessionConnector.password, db: MySQLSessionConnector.database, + port: UInt32(MySQLSessionConnector.port)) + #expect(connected, "can't reach MySQL at \(MySQLSessionConnector.host):\(MySQLSessionConnector.port): \(db.errorMessage())") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MYSQL_TESTS fails rather than passing with +/// the live suite skipped. +@Test func mysqlLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || MySQLLive.mysqlEnabled, "SESSION_REQUIRE_LIVE=1 but MYSQL_TESTS is not 1") +} + +@Test func mysqlLiveTestsHaveAPort() { + let requested = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" + #expect(!requested || MySQLLive.port != nil, "MYSQL_TESTS=1 needs MYSQL_PORT") } diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift index fc2b41f..71e7e17 100644 --- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift +++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift @@ -2,25 +2,50 @@ import Testing import Foundation @testable import PerfectSessionPostgreSQL import PerfectSessionCore +import PerfectPostgreSQL + +/// Live-test settings, outside the suite so its traits can refer to them. +enum PostgreSQLLive { + static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" +} // Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionPostgreSQLTests { +@Suite(.serialized, .enabled(if: PostgreSQLLive.pgEnabled, "set PG_TESTS=1 to run against a server")) struct PerfectSessionPostgreSQLTests { - static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" + /// Drops the test table once per run, so setup() creates it with the current schema. + static let freshTable: Void = { + let db = PGConnection() + defer { db.close() } + if db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok { + db.exec(statement: #"DROP TABLE IF EXISTS "test_sessions""#).clear() + } + }() + + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) -> Bool { + let db = PGConnection() + defer { db.close() } + guard db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok else { return false } + let result = db.exec(statement: #"SELECT 1 FROM "test_sessions" WHERE token = $1"#, params: [token]) + defer { result.clear() } + return result.numTuples() > 0 + } func getDriver() async -> PostgreSQLSessionDriver { PostgreSQLSessionConnector.host = ProcessInfo.processInfo.environment["PG_HOST"] ?? "localhost" PostgreSQLSessionConnector.username = ProcessInfo.processInfo.environment["PG_USER"] ?? "postgres" PostgreSQLSessionConnector.password = ProcessInfo.processInfo.environment["PG_PASSWORD"] ?? "" PostgreSQLSessionConnector.database = ProcessInfo.processInfo.environment["PG_DATABASE"] ?? "perfect_sessions_test" + PostgreSQLSessionConnector.port = Int(ProcessInfo.processInfo.environment["PG_PORT"] ?? "") ?? 5432 PostgreSQLSessionConnector.table = "test_sessions" + Self.freshTable let driver = PostgreSQLSessionDriver() await driver.setup() return driver } @Test func createAndResume() async throws { - guard Self.pgEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "192.168.1.1", useragent: "TestAgent/1.0") #expect(!session.token.isEmpty) @@ -32,7 +57,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.pgEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -40,7 +64,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.pgEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "pg-user-1" @@ -53,7 +76,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.pgEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -62,16 +84,241 @@ import PerfectSessionCore } } + @Test func saveAfterDestroyDoesNotResurrect() async { + let driver = await getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// The columns were 32-bit: an idle time past Int32.max failed to store, timestamps stop at + /// 2038, and clean() failed outright (integer out of range) once any row's updated + idle + /// passed Int32.max. + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = 3_000_000_000 + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.idle == 3_000_000_000) + #expect(resumed.created == session.created) + await driver.clean() + _ = try await driver.resume(token: session.token) + + session.idle = Int.max + await driver.save(session) + var expired = await driver.create() + expired.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(expired) + await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + #expect(!isStored(expired.token)) + await driver.destroy(token: session.token) + } + + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + await driver.destroy(token: session.token) + } + @Test func clean() async { - guard Self.pgEnabled else { return } let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.idle = -10 // save() renews `updated`, so expire it through idle await driver.save(session) + #expect(isStored(session.token)) await driver.clean() + #expect(!(isStored(session.token))) + } + + /// Under trust auth any password is accepted, so this shows the URI still parses. + @Test func passwordWithReservedCharactersConnects() async { + _ = await getDriver() + let saved = PostgreSQLSessionConnector.password + defer { PostgreSQLSessionConnector.password = saved } + PostgreSQLSessionConnector.password = saved.isEmpty ? "p@ss/w:rd?x#1" : saved + guard saved.isEmpty else { return } + let db = PGConnection() + defer { db.close() } + #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, "\(db.errorMessage())") + } + + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(isStored(old.token))) + #expect(isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(isStored(young.token)) + await driver.destroy(token: young.token) + } + + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async { + _ = await getDriver() + let db = PGConnection() + defer { db.close() } + #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, + "can't reach PostgreSQL: \(db.errorMessage())") + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets PG_TESTS fails rather than passing with +/// the live suite skipped. +@Test func postgreSQLLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || PostgreSQLLive.pgEnabled, "SESSION_REQUIRE_LIVE=1 but PG_TESTS is not 1") +} + +/// connectionString() percent-encoded with .urlQueryAllowed, which leaves "/", "@", ":" and "?" +/// alone, so a password containing one broke the URI. +@Test func postgreSQLConnectionStringEncodesReservedCharacters() { + #expect(PostgreSQLSessionConnector.connectionString( + username: "us:er", password: "p@ss/w:rd?x=1&y#z%", host: "db.example.com", port: 5433, + database: "my db/x") == + "postgresql://us%3Aer:p%40ss%2Fw%3Ard%3Fx%3D1%26y%23z%25@db.example.com:5433/my%20db%2Fx") + #expect(PostgreSQLSessionConnector.connectionString( + username: "u", password: "", host: "[::1]", port: 5432, database: "d") == "postgresql://u:@[::1]:5432/d") + #expect(PostgreSQLSessionConnector.connectionString( + username: "u", password: "", host: "/var/run/postgresql", port: 5432, database: "d") == + "postgresql://u:@%2Fvar%2Frun%2Fpostgresql:5432/d") } diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift index 66b7ff2..60b9f9d 100644 --- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift +++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift @@ -1,12 +1,22 @@ import Testing import Foundation +import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionRedis import PerfectSessionCore +import PerfectRedis -// Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionRedisTests { - +/// Live-test settings, outside the suite so its traits can refer to them. +enum RedisLive { static let redisEnabled = ProcessInfo.processInfo.environment["REDIS_TESTS"] == "1" +} + +// Serialized: each test sets the connector's global settings. +@Suite(.serialized, .enabled(if: RedisLive.redisEnabled, "set REDIS_TESTS=1 to run against a server")) struct PerfectSessionRedisTests { func getDriver() -> RedisSessionDriver { RedisSessionConnector.host = ProcessInfo.processInfo.environment["REDIS_HOST"] ?? "127.0.0.1" @@ -16,7 +26,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.redisEnabled else { return } let driver = getDriver() let session = await driver.create(ipaddress: "10.0.0.1", useragent: "RedisTest/1.0") #expect(!session.token.isEmpty) @@ -28,7 +37,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.redisEnabled else { return } let driver = getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -36,7 +44,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.redisEnabled else { return } let driver = getDriver() var session = await driver.create() session.userid = "redis-user-1" @@ -49,7 +56,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.redisEnabled else { return } let driver = getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -57,4 +63,361 @@ import PerfectSessionCore _ = try await driver.resume(token: session.token) } } + + func rawClient() async throws -> RedisClient { + try await RedisClient.connect(withIdentifier: RedisClientIdentifier( + withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, + password: RedisSessionConnector.password, commandTimeout: 10)) + } + + /// A save racing a logout used to SET the key again and bring the session back. + @Test func saveAfterDestroyDoesNotResurrect() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// The key's TTL follows the session's own idle time, not SessionConfig.idle. + @Test func expiryFollowsSessionIdle() async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = 1 + await driver.save(session) + try await Task.sleep(for: .milliseconds(1500)) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// A Redis call that gets no reply gives up after RedisSessionConnector.commandTimeout, + /// 5 seconds by default (it used to wait forever). + @Test func commandTimeoutDefaultsToFiveSeconds() async throws { + #expect(RedisSessionConnector.commandTimeout == 5) + let driver = getDriver() + let listener = try SilentListener() + defer { listener.close() } + RedisSessionConnector.host = "127.0.0.1" + RedisSessionConnector.port = listener.port + defer { _ = getDriver() } + let clock = ContinuousClock() + let start = clock.now + let outcome = await withTaskGroup(of: String.self) { group in + group.addTask { + do { + _ = try await driver.resume(token: "anything") + return "resumed" + } catch { + return "threw" + } + } + group.addTask { + try? await Task.sleep(for: .seconds(15)) + return "still waiting" + } + let first = await group.next() ?? "" + group.cancelAll() + return first + } + let elapsed = clock.now - start + #expect(outcome == "threw") + #expect(elapsed >= .seconds(4) && elapsed < .seconds(9), "\(elapsed)") + } + + /// An idle time near Int.max made SET's PX argument unrepresentable, so save() failed and + /// the session's new data was lost. + @Test(arguments: [Int.max, Int.max / 1000, 1 << 50]) + func hugeIdleStillSaves(_ idle: Int) async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = idle + session.data["saved"] = true + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.data["saved"] as? Bool == true) + #expect(resumed.idle == idle) + await driver.destroy(token: session.token) + } + + /// An idle time of 0 or less used to store the session with no TTL at all. + @Test func nonPositiveIdleDoesNotMakeASessionImmortal() async throws { + let driver = getDriver() + var session = await driver.create() + session.idle = 0 + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + /// Tokens come from the client's cookie. Session keys are namespaced, so a token can't name + /// (and resume or delete) one of the application's own keys. + @Test func tokenCannotReachOtherKeys() async throws { + let driver = getDriver() + let client = try await rawClient() + let key = "app:user:\(UUID().uuidString)" + _ = try await client.set(key: key, value: .string(#"{"userid":"admin","data":{}}"#)) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: key) + } + await driver.destroy(token: key) + #expect(try await client.get(key: key).string != nil) + _ = try await client.delete(keys: key) + try await client.close() + } + + /// Keys go to Redis as bulk strings, so separators, spaces and CR/LF in a token are just bytes. + @Test(arguments: ["a b", "a:b", "x\r\nFLUSHALL\r\n", "*1\r\n$4\r\nPING", "", "\u{0}"]) + func awkwardTokensAreJustMissingSessions(_ token: String) async throws { + let driver = getDriver() + let client = try await rawClient() + let canary = "canary:\(UUID().uuidString)" + _ = try await client.set(key: canary, value: .string("alive")) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + #expect(try await client.get(key: canary).string == "alive") + _ = try await client.delete(keys: canary) + try await client.close() + } + + @Test func keysAreNamespaced() async throws { + let driver = getDriver() + let session = await driver.create() + let client = try await rawClient() + #expect(try await client.get(key: RedisSessionConnector.keyPrefix + session.token).string != nil) + #expect(try await client.get(key: session.token).isNil) + try await client.close() + await driver.destroy(token: session.token) + } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) + } + + /// The key's TTL used to ignore SessionConfig.absoluteTimeout. + @Test func ttlHonoursAbsoluteTimeout() { + var s = PerfectSession() + s.created = 10_000 + s.updated = 10_100 + s.idle = 3600 + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == 3600) + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 500, now: 10_100) == 400) + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 50, now: 10_100) == 1) + s.idle = Int.max + #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == Double(RedisSessionDriver.maximumTTL)) + } + + /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached. + @Test func serverIsReachable() async throws { + _ = getDriver() + let client = try await RedisClient.connect(withIdentifier: RedisClientIdentifier( + withHost: RedisSessionConnector.host, port: RedisSessionConnector.port, + password: RedisSessionConnector.password, commandTimeout: 10)) + let pong = try await client.ping() + #expect(pong.string == "PONG") + try await client.close() + } +} + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets REDIS_TESTS fails rather than passing with +/// the live suite skipped. +@Test func redisLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || RedisLive.redisEnabled, "SESSION_REQUIRE_LIVE=1 but REDIS_TESTS is not 1") +} + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } } + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func redisLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + RedisSessionConnector.host = "127.0.0.1" + RedisSessionConnector.port = 1 // nothing listens: every call fails and logs + let driver = RedisSessionDriver() + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} + +/// A TCP listener that never accepts or answers: connections complete in the kernel's backlog, +/// then nothing is ever sent back. +final class SilentListener: @unchecked Sendable { + let fd: Int32 + let port: Int + + struct Failure: Error {} + + init() throws { + #if os(Linux) + let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + #else + let fd = socket(AF_INET, SOCK_STREAM, 0) + #endif + guard fd >= 0 else { throw Failure() } + self.fd = fd + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + address.sin_port = 0 + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, length) == 0 && listen(fd, 16) == 0 && getsockname(fd, $0, &length) == 0 + } + } + guard bound else { throw Failure() } + port = Int(UInt16(bigEndian: address.sin_port)) + } + + func close() { + #if os(Linux) + _ = Glibc.close(fd) + #else + _ = Darwin.close(fd) + #endif + } +} + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}() diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift index 036137a..75ca507 100644 --- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift +++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift @@ -1,12 +1,34 @@ import Testing import Foundation +import Logging +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif @testable import PerfectSessionSQLite import PerfectSessionCore +import PerfectSQLite + +/// Live-test settings, outside the suite so its traits can refer to them. +enum SQLiteLive { + static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1" +} // Serialized: each test sets the connector's global settings. -@Suite(.serialized) struct PerfectSessionSQLiteTests { +@Suite(.serialized, .enabled(if: SQLiteLive.sqliteEnabled, "set SQLITE_TESTS=1 to run")) struct PerfectSessionSQLiteTests { - static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1" + /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions + /// whether or not clean() deleted them. + func isStored(_ token: String) throws -> Bool { + let db = try SQLite(SQLiteSessionConnector.databasePath) + defer { db.close() } + var found = false + try db.forEachRow(statement: #"SELECT 1 FROM "test_sessions" WHERE token = ?"#, + doBindings: { try $0.bind(position: 1, token) }, + handleRow: { _, _ in found = true }) + return found + } func getDriver() async -> SQLiteSessionDriver { let tmp = FileManager.default.temporaryDirectory @@ -20,7 +42,6 @@ import PerfectSessionCore } @Test func createAndResume() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() let session = await driver.create(ipaddress: "127.0.0.1", useragent: "SQLiteTest/1.0") #expect(!session.token.isEmpty) @@ -32,7 +53,6 @@ import PerfectSessionCore } @Test func resumeMissing() async { - guard Self.sqliteEnabled else { return } let driver = await getDriver() await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: "no-such-token") @@ -40,7 +60,6 @@ import PerfectSessionCore } @Test func saveAndResume() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() var session = await driver.create() session.userid = "sqlite-user-1" @@ -53,7 +72,6 @@ import PerfectSessionCore } @Test func destroy() async { - guard Self.sqliteEnabled else { return } let driver = await getDriver() let session = await driver.create() await driver.destroy(token: session.token) @@ -62,21 +80,188 @@ import PerfectSessionCore } } - @Test func clean() async { - guard Self.sqliteEnabled else { return } + @Test func saveAfterDestroyDoesNotResurrect() async { let driver = await getDriver() var session = await driver.create() - session.idle = 1 - session.updated = Int(Date().timeIntervalSince1970) - 10 + session.userid = "logged-out" + await driver.destroy(token: session.token) + await driver.save(session) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + } + + @Test func timesPast32BitsRoundTrip() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = Int.max await driver.save(session) await driver.clean() + #expect(try await driver.resume(token: session.token).idle == Int.max) + await driver.destroy(token: session.token) + } + + /// resume() used to return an expired session (until clean() ran), leaving the check to + /// the caller. + @Test func resumeRejectsExpired() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) await #expect(throws: InvalidSessionError.self) { _ = try await driver.resume(token: session.token) } + await driver.destroy(token: session.token) + } + + @Test func clean() async throws { + let driver = await getDriver() + var session = await driver.create() + session.idle = -10 // save() renews `updated`, so expire it through idle + await driver.save(session) + #expect(try isStored(session.token)) + await driver.clean() + #expect(!(try isStored(session.token))) + } + + /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered: + /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.) + @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --", + "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"]) + func sqlInTokensIsJustText(_ token: String) async throws { + let driver = await getDriver() + var other = await driver.create() + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: token) + } + await driver.destroy(token: token) + other.userid = token + other.data[token] = token + await driver.save(other) + let resumed = try await driver.resume(token: other.token) + #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)") + #expect(resumed.data[token] as? String == token, "\(token.debugDescription)") + await driver.destroy(token: other.token) + } + + /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost. + @Test func concurrentSaveAndResume() async throws { + let driver = await getDriver() + let session = await driver.create() + try await withThrowingTaskGroup(of: Void.self) { group in + for i in 0..<40 { + group.addTask { + if i.isMultiple(of: 2) { + var copy = session + copy.data["n"] = i + await driver.save(copy) + } else { + _ = try await driver.resume(token: session.token) + } + } + } + try await group.waitForAll() + } + let final = try await driver.resume(token: session.token) + let n = try #require(final.data["n"] as? Int) + #expect(n.isMultiple(of: 2) && (0..<40).contains(n)) + #expect(final.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// One value JSON can't hold used to make the drivers save empty data, CSRF token included, + /// while the user stayed signed in. + @Test func nonJSONValueDropsOnlyThatKey() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "signed-in" + session.data["kept"] = "yes" + session.data["when"] = Date() + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect(resumed.userid == "signed-in") + #expect(resumed.data["kept"] as? String == "yes") + #expect(resumed.data["when"] == nil) + #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String) + await driver.destroy(token: session.token) + } + + /// A stored session without a CSRF token gets one when it's resumed. + @Test func resumeRestoresMissingCSRF() async throws { + let driver = await getDriver() + var session = await driver.create() + session.data.removeValue(forKey: "csrf") + await driver.save(session) + let resumed = try await driver.resume(token: session.token) + #expect((resumed.data["csrf"] as? String)?.isEmpty == false) + await driver.destroy(token: session.token) + } + + /// save() renews the idle time: a session saved within every `idle` seconds stays alive + /// past `idle` seconds from creation. Only Redis used to do this. + @Test func saveRenewsIdle() async throws { + let driver = await getDriver() + var session = await driver.create() + // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is + // past idle + 1 from creation, and 3 is under idle - 1 from each save. + session.idle = 5 + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + session = try await driver.resume(token: session.token) + await driver.save(session) + try await Task.sleep(for: .seconds(3)) + _ = try await driver.resume(token: session.token) + await driver.destroy(token: session.token) + } + + /// regenerate() used to restart the absolute timeout: the new session got a new `created`. + @Test func regenerateKeepsCreated() async throws { + let driver = await getDriver() + var session = await driver.create() + session.created -= 1000 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.created == session.created) + #expect(try await driver.resume(token: fresh.token).created == session.created) + await driver.destroy(token: fresh.token) + } + + /// clean() used to delete only sessions past their idle time, never ones past + /// SessionConfig.absoluteTimeout. + @Test func cleanHonoursAbsoluteTimeout() async throws { + let driver = await getDriver() + var old = await driver.create() + old.created -= 1000 + await driver.save(old) + let young = await driver.create() + await driver.clean(absoluteTimeout: 500) + #expect(!(try isStored(old.token))) + #expect(try isStored(young.token)) + await driver.clean(absoluteTimeout: 0) + #expect(try isStored(young.token)) + await driver.destroy(token: young.token) + } + + @Test func regenerateMovesSessionToNewToken() async throws { + let driver = await getDriver() + var session = await driver.create() + session.userid = "user-before-login" + session.data["cart"] = 3 + await driver.save(session) + let fresh = await driver.regenerate(session) + #expect(fresh.token != session.token) + await #expect(throws: InvalidSessionError.self) { + _ = try await driver.resume(token: session.token) + } + let resumed = try await driver.resume(token: fresh.token) + #expect(resumed.userid == "user-before-login") + #expect(resumed.data["cart"] as? Int == 3) + #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String) + #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String) + await driver.destroy(token: fresh.token) } @Test func dataRoundTrip() async throws { - guard Self.sqliteEnabled else { return } let driver = await getDriver() var session = await driver.create() session.data["string"] = "world" @@ -88,3 +273,63 @@ import PerfectSessionCore await driver.destroy(token: session.token) } } + +/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets SQLITE_TESTS fails rather than passing with +/// the live suite skipped. +@Test func sqliteLiveTestsRunWhenRequired() { + let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1" + #expect(!required || SQLiteLive.sqliteEnabled, "SESSION_REQUIRE_LIVE=1 but SQLITE_TESTS is not 1") +} + +/// Collects every log line, with its metadata, in this process. +final class CapturedLogs: @unchecked Sendable { + static let shared = CapturedLogs() + private let lock = NSLock() + private var lines: [String] = [] + func append(_ line: String) { lock.withLock { lines.append(line) } } + var text: String { lock.withLock { lines.joined(separator: "\n") } } +} + +struct CapturingLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .trace + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + func log(event: LogEvent) { + CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)") + } +} + +/// Failures used to log the full session token as `eventid`; a token in a log is a session +/// anyone with log access can take over. Runs in a child process so it can bootstrap logging. +@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer")) +func sqliteLogsDoNotContainTokens() async { + await #expect(processExitsWith: .success) { + LoggingSystem.bootstrap { _ in CapturingLogHandler() } + SQLiteSessionConnector.databasePath = "/nonexistent-dir/sessions.db" // every call fails and logs + let driver = SQLiteSessionDriver() + var session = await driver.create() + session.data["x"] = 1 + await driver.save(session) + _ = try? await driver.resume(token: session.token) + await driver.destroy(token: session.token) + let logs = CapturedLogs.shared.text + let leaked = logs.contains(session.token) + let identified = logs.contains(PerfectSession.logID(session.token)) + if leaked || !identified { print(logs) } + exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE) + } +} + +/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only +/// calls exit(0)), so they're skipped there. +let underThreadSanitizer: Bool = { + #if os(Linux) + let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT + #else + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + #endif + return dlsym(defaultHandle, "__tsan_init") != nil +}()