From 97aaf0b77dbaad155f337c8557a3bbf3152348dd Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 24 Jan 2024 16:37:04 +0000 Subject: [PATCH] Update webauthn example for 2.x.x (#54) * Update webauthn for 2.x.x * Using HummingbirdRouter * Update README * Missed a comment * Fix hello app * Update webauthn/README.md Co-authored-by: Joannis Orlandos --------- Co-authored-by: Joannis Orlandos --- README.md | 2 +- hello/Package.swift | 1 - webauthn/Package.swift | 14 +- webauthn/README.md | 2 +- webauthn/Sources/App/App.swift | 15 +-- webauthn/Sources/App/Application+build.swift | 91 +++++++++++++ .../Sources/App/Application+configure.swift | 83 ------------ .../App/Controllers/HTMLController.swift | 44 ++++-- .../App/Controllers/WebAuthnController.swift | 108 ++++++++------- webauthn/Sources/App/Extensions/html.swift | 6 +- .../Middleware/WebAuthnAuthenticator.swift | 127 +++++++++++------- webauthn/Sources/App/Models/User.swift | 9 +- webauthn/Sources/App/RequestContext.swift | 17 +++ webauthn/Tests/AppTests/AppTests.swift | 17 ++- webauthn/public/js/webauthn.js | 28 ++-- 15 files changed, 318 insertions(+), 246 deletions(-) create mode 100644 webauthn/Sources/App/Application+build.swift delete mode 100644 webauthn/Sources/App/Application+configure.swift create mode 100644 webauthn/Sources/App/RequestContext.swift diff --git a/README.md b/README.md index 9ec627ad..87552830 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Examples converted to Hummingbird 2.0 - [todos-mongokitten-openapi](https://github.com/hummingbird-project/hummingbird-examples/tree/2.x.x/todos-mongokitten-openapi) - Todos application, using MongoDB driver [MongoKitten](https://github.com/orlandos-nl/MongoKitten) and the [OpenAPI runtime](https://github.com/apple/swift-openapi-runtime). - [todos-postgres-tutorial](https://github.com/hummingbird-project/hummingbird-examples/tree/2.x.x/todos-postgres-tutorial) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using PostgresNIO. Sample code that goes along with the [Todos tutorial](https://hummingbird-project.github.io/hummingbird-docs/2.0/tutorials/todos). - [upload](https://github.com/hummingbird-project/hummingbird-examples/tree/2.x.x/upload) - File uploading and downloading. +- [webauthn](https://github.com/hummingbird-project/hummingbird-examples/tree/2.x.x/webauthn) - Web app demonstrating WebAuthn(PassKey) authentication. Examples still working with Hummingbird 1.0 - [auth-cognito](https://github.com/hummingbird-project/hummingbird-examples/tree/main/auth-cognito) - Authentication via AWS Cognito. @@ -23,7 +24,6 @@ Examples still working with Hummingbird 1.0 - [todos-fluent](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-fluent) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using Fluent - [todos-lambda](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-lambda) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using DynamoDB and running on AWS Lambda. - [upload-s3](https://github.com/hummingbird-project/hummingbird-examples/tree/main/upload-s3) - File uploading and downloading using AWS S3 as backing store. -- [webauthn](https://github.com/hummingbird-project/hummingbird-examples/tree/main/webauthn) - Web app demonstrating WebAuthn(PassKey) authentication. - [websocket-chat](https://github.com/hummingbird-project/hummingbird-examples/tree/main/websocket-chat) - Simple chat application using WebSockets. - [websocket-echo](https://github.com/hummingbird-project/hummingbird-examples/tree/main/websocket-echo) - Simple WebSocket based echo server. diff --git a/hello/Package.swift b/hello/Package.swift index 6aa4c623..6ace5b67 100644 --- a/hello/Package.swift +++ b/hello/Package.swift @@ -16,7 +16,6 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Hummingbird", package: "hummingbird"), - .product(name: "HummingbirdFoundation", package: "hummingbird"), ], swiftSettings: [ // Enable better optimizations when building in Release configuration. Despite the use of diff --git a/webauthn/Package.swift b/webauthn/Package.swift index f577c3be..e158f86d 100644 --- a/webauthn/Package.swift +++ b/webauthn/Package.swift @@ -1,19 +1,18 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "webauthn", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v14)], products: [ .executable(name: "App", targets: ["App"]), ], dependencies: [ - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.2.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "1.0.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird-auth.git", from: "1.2.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird-fluent.git", from: "1.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0-alpha.1"), + .package(url: "https://github.com/hummingbird-project/hummingbird-auth.git", from: "2.0.0-alpha.1"), + .package(url: "https://github.com/hummingbird-project/hummingbird-fluent.git", from: "2.0.0-alpha.1"), .package(url: "https://github.com/hummingbird-project/hummingbird-mustache.git", from: "1.0.0"), .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), .package(url: "https://github.com/swift-server/webauthn-swift.git", from: "1.0.0-alpha"), @@ -26,10 +25,10 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdRouter", package: "hummingbird"), .product(name: "HummingbirdAuth", package: "hummingbird-auth"), .product(name: "HummingbirdFluent", package: "hummingbird-fluent"), .product(name: "HummingbirdMustache", package: "hummingbird-mustache"), - .product(name: "HummingbirdTLS", package: "hummingbird-core"), .product(name: "WebAuthn", package: "webauthn-swift"), ], swiftSettings: [ @@ -43,6 +42,7 @@ let package = Package( name: "AppTests", dependencies: [ .byName(name: "App"), + .product(name: "Hummingbird", package: "hummingbird"), .product(name: "HummingbirdXCT", package: "hummingbird"), ] ), diff --git a/webauthn/README.md b/webauthn/README.md index 6e6e7ed1..ea318c61 100644 --- a/webauthn/README.md +++ b/webauthn/README.md @@ -1,5 +1,5 @@ # WebAuthn -Application demostrating authentication via WebAuthn passkeys. This example uses the webAuthn library https://github.com/swift-server/webauthn-swift. +Application demostrating authentication via WebAuthn passkeys. This example uses the webAuthn library https://github.com/swift-server/webauthn-swift. The sample also uses the result builder router `HBRouterBuilder` from HummingbirdRouter In your browser go to `localhost:8080`. diff --git a/webauthn/Sources/App/App.swift b/webauthn/Sources/App/App.swift index 3136e637..58e5ae42 100644 --- a/webauthn/Sources/App/App.swift +++ b/webauthn/Sources/App/App.swift @@ -16,7 +16,7 @@ import ArgumentParser import Hummingbird @main -struct App: ParsableCommand, AppArguments { +struct App: AsyncParsableCommand, AppArguments { @Option(name: .shortAndLong) var hostname: String = "127.0.0.1" @@ -29,15 +29,8 @@ struct App: ParsableCommand, AppArguments { var privateKey: String { "certs/server.key" } var certificateChain: String { "certs/server.crt" } - func run() throws { - let app = HBApplication( - configuration: .init( - address: .hostname(self.hostname, port: self.port), - serverName: "Hummingbird" - ) - ) - try app.configure(self) - try app.start() - app.wait() + func run() async throws { + let app = try await buildApplication(self) + try await app.runService() } } diff --git a/webauthn/Sources/App/Application+build.swift b/webauthn/Sources/App/Application+build.swift new file mode 100644 index 00000000..562bf410 --- /dev/null +++ b/webauthn/Sources/App/Application+build.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2021-2023 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import FluentSQLiteDriver +import Foundation +import Hummingbird +import HummingbirdAuth +import HummingbirdFluent +import HummingbirdMustache +import HummingbirdRouter +import WebAuthn + +/// Application arguments protocol. We use a protocol so we can call +/// `HBApplication.configure` inside Tests as well as in the App executable. +/// Any variables added here also have to be added to `App` in App.swift and +/// `TestArguments` in AppTest.swift +public protocol AppArguments { + var hostname: String { get } + var port: Int { get } + var inMemoryDatabase: Bool { get } + var certificateChain: String { get } + var privateKey: String { get } +} + +func buildApplication(_ arguments: AppArguments) async throws -> some HBApplicationProtocol { + var logger = Logger(label: "webauthn") + logger.logLevel = .debug + + let fluent = HBFluent(logger: logger) + // add sqlite database + if arguments.inMemoryDatabase { + fluent.databases.use(.sqlite(.memory), as: .sqlite) + } else { + fluent.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) + } + await fluent.migrations.add(CreateUser()) + await fluent.migrations.add(CreateWebAuthnCredential()) + try await fluent.migrate() + + // sessions are stored in memory + let memoryPersist = HBMemoryPersistDriver() + let sessionStorage = HBSessionStorage(memoryPersist) + + // load mustache template library + let library = try HBMustacheLibrary(directory: "templates") + assert(library.getTemplate(named: "home") != nil, "Set your working directory to the root folder of this example to get it to work") + + let router = HBRouterBuilder(context: WebAuthnRequestContext.self) { + // add logging middleware + HBLogRequestsMiddleware(.info) + // add file middleware to server HTML files + HBFileMiddleware(searchForIndexHtml: true, logger: logger) + // health check endpoint + Get("/health") { _, _ -> HTTPResponse.Status in + return .ok + } + HTMLController( + mustacheLibrary: library, + fluent: fluent, + sessionStorage: sessionStorage + ).endpoints + RouteGroup("api") { + HBWebAuthnController( + webauthn: .init( + config: .init( + relyingPartyID: "localhost", + relyingPartyName: "Hummingbird WebAuthn example", + relyingPartyOrigin: "http://localhost:8080" + ) + ), + fluent: fluent, + sessionStorage: sessionStorage + ).endpoints + } + } + + var app = HBApplication(router: router) + app.addServices(fluent, memoryPersist) + return app +} diff --git a/webauthn/Sources/App/Application+configure.swift b/webauthn/Sources/App/Application+configure.swift deleted file mode 100644 index 0712b06c..00000000 --- a/webauthn/Sources/App/Application+configure.swift +++ /dev/null @@ -1,83 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Hummingbird server framework project -// -// Copyright (c) 2021-2023 the Hummingbird authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import FluentSQLiteDriver -import Foundation -import Hummingbird -import HummingbirdFluent -import HummingbirdFoundation -import HummingbirdMustache -import HummingbirdTLS -import WebAuthn - -/// Application arguments protocol. We use a protocol so we can call -/// `HBApplication.configure` inside Tests as well as in the App executable. -/// Any variables added here also have to be added to `App` in App.swift and -/// `TestArguments` in AppTest.swift -public protocol AppArguments { - var inMemoryDatabase: Bool { get } - var certificateChain: String { get } - var privateKey: String { get } -} - -extension HBApplication { - /// configure your application - /// add middleware - /// setup the encoder/decoder - /// add your routes - func configure(_ arguments: AppArguments) throws { - // Add TLS - // try server.addTLS(tlsConfiguration: self.getTLSConfig(arguments)) - - self.encoder = JSONEncoder() - self.decoder = JSONDecoder() - - self.addFluent() - // add sqlite database - if arguments.inMemoryDatabase { - self.fluent.databases.use(.sqlite(.memory), as: .sqlite) - } else { - self.fluent.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite) - } - // add migrations - self.fluent.migrations.add(CreateUser()) - self.fluent.migrations.add(CreateWebAuthnCredential()) - try self.fluent.migrate().wait() - - self.addSessions(using: .memory) - - self.router.middlewares.add(HBLogRequestsMiddleware(.info)) - self.router.middlewares.add(HBFileMiddleware(searchForIndexHtml: true, application: self)) - self.router.get("/health") { _ -> HTTPResponseStatus in - return .ok - } - - // load mustache template library - let library = try HBMustacheLibrary(directory: "templates") - assert(library.getTemplate(named: "home") != nil, "Set your working directory to the root folder of this example to get it to work") - - // Add WebAuthn routes - HTMLController(mustacheLibrary: library).addRoutes(to: self.router) - // Add WebAuthn routes - HBWebAuthnController( - webauthn: .init( - config: .init( - relyingPartyID: "localhost", - relyingPartyName: "Hummingbird WebAuthn example", - relyingPartyOrigin: "http://localhost:8080" - ) - ) - ).add(to: self.router.group("api")) - } -} diff --git a/webauthn/Sources/App/Controllers/HTMLController.swift b/webauthn/Sources/App/Controllers/HTMLController.swift index 29f7e04d..e8aa6d9c 100644 --- a/webauthn/Sources/App/Controllers/HTMLController.swift +++ b/webauthn/Sources/App/Controllers/HTMLController.swift @@ -13,45 +13,61 @@ //===----------------------------------------------------------------------===// import Hummingbird +import HummingbirdAuth +import HummingbirdFluent import HummingbirdMustache +import HummingbirdRouter /// Redirects to login page if no user has been authenticated -struct RedirectMiddleware: HBMiddleware { +struct RedirectMiddleware: HBMiddlewareProtocol { let to: String - func apply(to request: HBRequest, next: HBResponder) -> EventLoopFuture { - if request.authHas(User.self) { - return next.respond(to: request) + func handle(_ request: HBRequest, context: Context, next: (HBRequest, Context) async throws -> HBResponse) async throws -> HBResponse { + // check if authenticated + if context.auth.has(AuthenticatedUser.self) { + return try await next(request, context) } else { - return request.eventLoop.makeSucceededFuture(.redirect(to: "\(self.to)?from=\(request.uri)", type: .found)) + // if not authenticated then redirect to login page + return .redirect(to: "\(self.to)?from=\(request.uri)", type: .found) } } } /// Serves HTML pages struct HTMLController { + typealias Context = WebAuthnRequestContext + let homeTemplate: HBMustacheTemplate + let fluent: HBFluent + let sessionStorage: HBSessionStorage - init(mustacheLibrary: HBMustacheLibrary) { + init( + mustacheLibrary: HBMustacheLibrary, + fluent: HBFluent, + sessionStorage: HBSessionStorage + ) { // get the mustache templates from the library guard let homeTemplate = mustacheLibrary.getTemplate(named: "home") else { preconditionFailure("Failed to load mustache templates") } self.homeTemplate = homeTemplate + self.fluent = fluent + self.sessionStorage = sessionStorage } - /// Add routes for webpages - func addRoutes(to router: HBRouterBuilder) { - router.group() - .add(middleware: WebAuthnSessionAuthenticator()) - .add(middleware: RedirectMiddleware(to: "/login.html")) - .get("/", use: self.home) + // return Route for home page + var endpoints: some HBMiddlewareProtocol { + Get("/") { + WebAuthnSessionAuthenticator(fluent: self.fluent, sessionStorage: self.sessionStorage) + RedirectMiddleware(to: "/login.html") + self.home + } } /// Home page listing todos and with add todo UI - func home(request: HBRequest) async throws -> HTML { + @Sendable func home(request: HBRequest, context: Context) async throws -> HTML { // get user - let user = try request.authRequire(User.self) + let user = try context.auth.require(AuthenticatedUser.self) // Render home template and return as HTML let object: [String: Any] = [ "name": user.username, diff --git a/webauthn/Sources/App/Controllers/WebAuthnController.swift b/webauthn/Sources/App/Controllers/WebAuthnController.swift index 663511f7..c8fde054 100644 --- a/webauthn/Sources/App/Controllers/WebAuthnController.swift +++ b/webauthn/Sources/App/Controllers/WebAuthnController.swift @@ -16,65 +16,79 @@ import FluentKit import Foundation import Hummingbird import HummingbirdAuth -import HummingbirdCore +import HummingbirdFluent +import HummingbirdRouter import WebAuthn struct HBWebAuthnController { + typealias Context = WebAuthnRequestContext + let webauthn: WebAuthnManager + let fluent: HBFluent + let sessionStorage: HBSessionStorage - func add(to group: HBRouterGroup) { - group - .post("signup", options: .editResponse, use: self.signin) - .get("login", options: .editResponse, use: self.beginAuthentication) - group - .add(middleware: WebAuthnSessionStateAuthenticator()) - .post("register/start", use: self.beginRegistration) - .post("register/finish", use: self.finishRegistration) - .post("login", options: .editResponse, use: self.finishAuthentication) - group - .add(middleware: WebAuthnSessionAuthenticator()) - .get("logout", options: .editResponse, use: self.logout) + // return RouteGroup with user login endpoints + var endpoints: some HBMiddlewareProtocol { + RouteGroup("user") { + Post("signup", handler: self.signin) + Get("login", handler: self.beginAuthentication) + Post("login") { + WebAuthnSessionStateAuthenticator(fluent: self.fluent, sessionStorage: self.sessionStorage) + self.finishAuthentication + } + Get("logout") { + WebAuthnSessionAuthenticator(fluent: self.fluent, sessionStorage: self.sessionStorage) + self.logout + } + RouteGroup("register") { + WebAuthnSessionStateAuthenticator(fluent: self.fluent, sessionStorage: self.sessionStorage) + Post("start", handler: self.beginRegistration) + Post("finish", handler: self.finishRegistration) + } + } } struct SignInInput: Decodable { let username: String } - func signin(request: HBRequest) async throws -> HBResponse { - let input = try request.decode(as: SignInInput.self) - guard try await User.query(on: request.db) + @Sendable func signin(request: HBRequest, context: Context) async throws -> HBResponse { + let input = try await request.decode(as: SignInInput.self, context: context) + guard try await User.query(on: self.fluent.db()) .filter(\.$username == input.username) .first() == nil else { throw HBHTTPError(.conflict, message: "Username already taken.") } let user = User(username: input.username) - try await user.save(on: request.db) - let session = try WebAuthnSessionStateAuthenticator.Session.signedUp(userId: user.requireID()) - try await request.session.save( + try await user.save(on: self.fluent.db()) + let session = try WebAuthnSession.signedUp(userId: user.requireID()) + let cookie = try await self.sessionStorage.save( session: session, - expiresIn: .minutes(10) + expiresIn: .seconds(600) ) - return .redirect(to: "/api/register/start", type: .temporary) + var response = HBResponse.redirect(to: "/api/user/register/start", type: .temporary) + response.setCookie(cookie) + return response } /// Begin registering a User - func beginRegistration(request: HBRequest) async throws -> PublicKeyCredentialCreationOptions { - let authenticationSession = try request.authRequire(AuthenticationSession.self) + @Sendable func beginRegistration(request: HBRequest, context: Context) async throws -> PublicKeyCredentialCreationOptions { + let authenticationSession = try context.auth.require(AuthenticationSession.self) guard case .signedUp(let user) = authenticationSession else { throw HBHTTPError(.unauthorized) } let options = self.webauthn.beginRegistration(user: user.publicKeyCredentialUserEntity) - let session = WebAuthnSessionStateAuthenticator.Session(from: .registering( + let session = WebAuthnSession(from: .registering( user: user, challenge: options.challenge )) - try await request.session.update(session: session, expiresIn: .minutes(10)) + try await self.sessionStorage.update(session: session, expiresIn: .seconds(600), request: request) return options } /// Finish registering a user - func finishRegistration(request: HBRequest) async throws -> HTTPResponseStatus { - let authenticationSession = try request.authRequire(AuthenticationSession.self) - let input = try request.decode(as: RegistrationCredential.self) + @Sendable func finishRegistration(request: HBRequest, context: Context) async throws -> HTTPResponse.Status { + let authenticationSession = try context.auth.require(AuthenticationSession.self) + let input = try await request.decode(as: RegistrationCredential.self, context: context) guard case .registering(let user, let challenge) = authenticationSession else { throw HBHTTPError(.unauthorized) } do { let credential = try await self.webauthn.finishRegistration( @@ -82,36 +96,38 @@ struct HBWebAuthnController { credentialCreationData: input, // this is likely to be removed soon confirmCredentialIDNotRegisteredYet: { id in - return try await WebAuthnCredential.query(on: request.db).filter(\.$id == id).first() == nil + return try await WebAuthnCredential.query(on: self.fluent.db()).filter(\.$id == id).first() == nil } ) - try await WebAuthnCredential(credential: credential, userId: user.requireID()).save(on: request.db) + try await WebAuthnCredential(credential: credential, userId: user.id).save(on: self.fluent.db()) } catch { - request.logger.error("\(error)") + context.logger.error("\(error)") throw HBHTTPError(.unauthorized) } - request.logger.info("Registration success, id: \(input.id)") + context.logger.info("Registration success, id: \(input.id)") return .ok } /// Begin Authenticating a user - func beginAuthentication(_ request: HBRequest) async throws -> PublicKeyCredentialRequestOptions { + @Sendable func beginAuthentication(_ request: HBRequest, context: Context) async throws -> HBEditedResponse { let options = try self.webauthn.beginAuthentication(timeout: 60000) - let session = WebAuthnSessionAuthenticator.Session(from: .authenticating( + let session = WebAuthnSession(from: .authenticating( challenge: options.challenge )) - try await request.session.save(session: session, expiresIn: .minutes(10)) - return options + let cookie = try await sessionStorage.save(session: session, expiresIn: .seconds(600)) + var editedResponse = HBEditedResponse(response: options) + editedResponse.setCookie(cookie) + return editedResponse } /// End Authenticating a user - func finishAuthentication(request: HBRequest) async throws -> HTTPResponseStatus { - let authenticationSession = try request.authRequire(AuthenticationSession.self) - let input = try request.decode(as: AuthenticationCredential.self) + @Sendable func finishAuthentication(request: HBRequest, context: Context) async throws -> HTTPResponse.Status { + let authenticationSession = try context.auth.require(AuthenticationSession.self) + let input = try await request.decode(as: AuthenticationCredential.self, context: context) guard case .authenticating(let challenge) = authenticationSession else { throw HBHTTPError(.unauthorized) } let id = input.id.urlDecoded.asString() - guard let webAuthnCredential = try await WebAuthnCredential.query(on: request.db) + guard let webAuthnCredential = try await WebAuthnCredential.query(on: fluent.db()) .filter(\.$id == id) .with(\.$user) .first() @@ -119,7 +135,7 @@ struct HBWebAuthnController { throw HBHTTPError(.unauthorized) } guard let decodedPublicKey = webAuthnCredential.publicKey.decoded else { throw HBHTTPError(.internalServerError) } - request.logger.info("Challenge: \(challenge)") + context.logger.info("Challenge: \(challenge)") do { _ = try self.webauthn.finishAuthentication( credential: input, @@ -128,18 +144,18 @@ struct HBWebAuthnController { credentialCurrentSignCount: 0 ) } catch { - request.logger.error("\(error)") + context.logger.error("\(error)") throw HBHTTPError(.unauthorized) } - let session = try WebAuthnSessionAuthenticator.Session.authenticated(userId: webAuthnCredential.user.requireID()) - try await request.session.update(session: session, expiresIn: .hours(24)) + let session = try WebAuthnSession.authenticated(userId: webAuthnCredential.user.requireID()) + try await self.sessionStorage.update(session: session, expiresIn: .seconds(24 * 60 * 60), request: request) return .ok } /// Test authenticated - func logout(_ request: HBRequest) async throws -> HTTPResponseStatus { - try await request.session.delete() + @Sendable func logout(_ request: HBRequest, context: Context) async throws -> HTTPResponse.Status { + try await self.sessionStorage.delete(request: request) return .ok } } diff --git a/webauthn/Sources/App/Extensions/html.swift b/webauthn/Sources/App/Extensions/html.swift index 9ecd2d36..497beb2f 100644 --- a/webauthn/Sources/App/Extensions/html.swift +++ b/webauthn/Sources/App/Extensions/html.swift @@ -19,8 +19,8 @@ import Hummingbird struct HTML: HBResponseGenerator { let html: String - public func response(from request: HBRequest) throws -> HBResponse { - let buffer = request.allocator.buffer(string: self.html) - return .init(status: .ok, headers: ["content-type": "text/html"], body: .byteBuffer(buffer)) + public func response(from request: HBRequest, context: some HBBaseRequestContext) throws -> HBResponse { + let buffer = context.allocator.buffer(string: self.html) + return .init(status: .ok, headers: [.contentType: "text/html"], body: .init(byteBuffer: buffer)) } } diff --git a/webauthn/Sources/App/Middleware/WebAuthnAuthenticator.swift b/webauthn/Sources/App/Middleware/WebAuthnAuthenticator.swift index 496fd3ae..352956e1 100644 --- a/webauthn/Sources/App/Middleware/WebAuthnAuthenticator.swift +++ b/webauthn/Sources/App/Middleware/WebAuthnAuthenticator.swift @@ -13,70 +13,99 @@ //===----------------------------------------------------------------------===// import Foundation +import Hummingbird import HummingbirdAuth +import HummingbirdFluent import WebAuthn -/// Authentication state -enum AuthenticationSession: Codable, HBAuthenticatable, HBResponseEncodable { - case signedUp(user: User) - case registering(user: User, challenge: [UInt8]) +/// cannot conform fluent model `User` to `HBAuthenticatable` as it is not Sendable +/// so create a copy to store in login cache +struct AuthenticatedUser: HBAuthenticatable, Codable { + var id: UUID + var username: String + + var publicKeyCredentialUserEntity: PublicKeyCredentialUserEntity { + .init(id: .init(self.id.uuidString.utf8), name: self.username, displayName: self.username) + } +} + +/// Authentication state stored in login cache +enum AuthenticationSession: Sendable, Codable, HBAuthenticatable, HBResponseEncodable { + case signedUp(user: AuthenticatedUser) + case registering(user: AuthenticatedUser, challenge: [UInt8]) case authenticating(challenge: [UInt8]) - case authenticated(user: User) + case authenticated(user: AuthenticatedUser) } -/// Authenticator that will return current state of authentication -struct WebAuthnSessionStateAuthenticator: HBAsyncSessionAuthenticator { - /// Session object saved to storage - enum Session: Codable { - case signedUp(userId: UUID) - case registering(userId: UUID, encodedChallenge: String) - case authenticating(encodedChallenge: String) - case authenticated(userId: UUID) +/// Session object saved to storage +enum WebAuthnSession: Codable { + case signedUp(userId: UUID) + case registering(userId: UUID, encodedChallenge: String) + case authenticating(encodedChallenge: String) + case authenticated(userId: UUID) - /// init session object from authentication state - init(from session: AuthenticationSession) { - switch session { - case .authenticating(let challenge): - self = .authenticating(encodedChallenge: challenge.base64URLEncodedString().asString()) - case .signedUp(let user): - self = .signedUp(userId: user.id!) - case .registering(let user, let challenge): - self = .registering(userId: user.id!, encodedChallenge: challenge.base64URLEncodedString().asString()) - case .authenticated(let user): - self = .authenticated(userId: user.id!) - } + /// init session object from authentication state + init(from session: AuthenticationSession) { + switch session { + case .authenticating(let challenge): + self = .authenticating(encodedChallenge: challenge.base64URLEncodedString().asString()) + case .signedUp(let user): + self = .signedUp(userId: user.id) + case .registering(let user, let challenge): + self = .registering(userId: user.id, encodedChallenge: challenge.base64URLEncodedString().asString()) + case .authenticated(let user): + self = .authenticated(userId: user.id) } + } - /// return authentication state from session object - func session(for request: HBRequest) async throws -> AuthenticationSession? { - switch self { - case .authenticating(let encodedChallenge): - guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } - return .authenticating(challenge: challenge) - case .signedUp(let userId): - guard let user = try await User.find(userId, on: request.db) else { return nil } - return .signedUp(user: user) - case .registering(let userId, let encodedChallenge): - guard let user = try await User.find(userId, on: request.db) else { return nil } - guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } - return .registering(user: user, challenge: challenge) - case .authenticated(let userId): - guard let user = try await User.find(userId, on: request.db) else { return nil } - return .authenticated(user: user) - } + /// return authentication state from session object + func session(for request: HBRequest, fluent: HBFluent) async throws -> AuthenticationSession? { + switch self { + case .authenticating(let encodedChallenge): + guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } + return .authenticating(challenge: challenge) + case .signedUp(let userId): + guard let user = try await User.find(userId, on: fluent.db()) else { return nil } + return .signedUp(user: .init(id: userId, username: user.username)) + case .registering(let userId, let encodedChallenge): + guard let user = try await User.find(userId, on: fluent.db()) else { return nil } + guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } + return .registering(user: .init(id: userId, username: user.username), challenge: challenge) + case .authenticated(let userId): + guard let user = try await User.find(userId, on: fluent.db()) else { return nil } + return .authenticated(user: .init(id: userId, username: user.username)) } } +} + +/// Authenticator that will return current state of authentication +struct WebAuthnSessionStateAuthenticator: HBSessionAuthenticator { + typealias Session = WebAuthnSession + /// fluent reference + let fluent: HBFluent + /// container for session objects + let sessionStorage: HBSessionStorage - func getValue(from session: Session, request: HBRequest) async throws -> AuthenticationSession? { - return try await session.session(for: request) + func getValue(from session: Session, request: HBRequest, context: Context) async throws -> AuthenticationSession? { + return try await session.session(for: request, fluent: self.fluent) } } -/// Authenticator that will return an authenticated user -struct WebAuthnSessionAuthenticator: HBAsyncSessionAuthenticator { - typealias Session = WebAuthnSessionStateAuthenticator.Session - func getValue(from session: Session, request: HBRequest) async throws -> User? { +/// Authenticator that will return an authenticated user from a WebAuthnSession +struct WebAuthnSessionAuthenticator: HBSessionAuthenticator { + typealias Session = WebAuthnSession + + /// fluent reference + let fluent: HBFluent + /// container for session objects + let sessionStorage: HBSessionStorage + + func getValue(from session: Session, request: HBRequest, context: Context) async throws -> AuthenticatedUser? { guard case .authenticated(let userId) = session else { return nil } - return try await User.find(userId, on: request.db) + if let user = try await User.find(userId, on: self.fluent.db()) { + return AuthenticatedUser(id: userId, username: user.username) + } else { + return nil + } } } diff --git a/webauthn/Sources/App/Models/User.swift b/webauthn/Sources/App/Models/User.swift index e1304444..ef2873fe 100644 --- a/webauthn/Sources/App/Models/User.swift +++ b/webauthn/Sources/App/Models/User.swift @@ -1,10 +1,11 @@ import FluentSQLiteDriver import Foundation +import Hummingbird import HummingbirdAuth import HummingbirdFluent import WebAuthn -final class User: Model, HBAuthenticatable, HBResponseEncodable { +final class User: Model, HBResponseEncodable { static let schema = "user" @ID(key: .id) @@ -19,9 +20,3 @@ final class User: Model, HBAuthenticatable, HBResponseEncodable { self.username = username } } - -extension User { - var publicKeyCredentialUserEntity: PublicKeyCredentialUserEntity { - .init(id: .init(self.id!.uuidString.utf8), name: self.username, displayName: self.username) - } -} diff --git a/webauthn/Sources/App/RequestContext.swift b/webauthn/Sources/App/RequestContext.swift new file mode 100644 index 00000000..5a773f4f --- /dev/null +++ b/webauthn/Sources/App/RequestContext.swift @@ -0,0 +1,17 @@ +import Hummingbird +import HummingbirdAuth +import HummingbirdRouter +import Logging +import NIOCore + +struct WebAuthnRequestContext: HBAuthRequestContextProtocol, HBRouterRequestContext { + var coreContext: HBCoreRequestContext + var auth: HBLoginCache + var routerContext: HBRouterBuilderContext + + init(allocator: ByteBufferAllocator, logger: Logger) { + self.coreContext = .init(allocator: allocator, logger: logger) + self.auth = .init() + self.routerContext = .init() + } +} diff --git a/webauthn/Tests/AppTests/AppTests.swift b/webauthn/Tests/AppTests/AppTests.swift index 78c7ac74..7e25a9c2 100644 --- a/webauthn/Tests/AppTests/AppTests.swift +++ b/webauthn/Tests/AppTests/AppTests.swift @@ -19,21 +19,20 @@ import XCTest final class AppTests: XCTestCase { struct TestArguments: AppArguments { + var hostname: String { "127.0.0.1" } + var port: Int { 8080 } var inMemoryDatabase: Bool { true } var privateKey: String { "certs/server.key" } var certificateChain: String { "certs/server.crt" } } - func testApp() throws { + func testApp() async throws { let args = TestArguments() - let app = HBApplication(testing: .live) - try app.configure(args) - - try app.XCTStart() - defer { XCTAssertNoThrow(app.XCTStop()) } - - try app.XCTExecute(uri: "/health", method: .GET) { response in - XCTAssertEqual(response.status, .ok) + let app = try await buildApplication(args) + try await app.test(.router) { client in + try await client.XCTExecute(uri: "/health", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } } } } diff --git a/webauthn/public/js/webauthn.js b/webauthn/public/js/webauthn.js index fb3aacf5..c455d53d 100644 --- a/webauthn/public/js/webauthn.js +++ b/webauthn/public/js/webauthn.js @@ -25,7 +25,7 @@ async function register(username) { "username": username } // signup api call - const response = await fetch('/api/signup', { + const response = await fetch('/api/user/signup', { method: 'POST', headers: {"content-type": "application/json"}, body: JSON.stringify(data) @@ -43,7 +43,7 @@ async function register(username) { const result = await navigator.credentials.create({publicKey: publicKeyCredentialCreationOptions}); const registrationCredential = createRegistrationCredentialForServer(result); // finish registration api call - const finishResponse = await fetch('/api/register/finish', { + const finishResponse = await fetch('/api/user/register/finish', { method: "POST", headers: {"content-type": "application/json"}, body: JSON.stringify(registrationCredential) @@ -63,7 +63,7 @@ async function register(username) { async function login() { try { // initiate login - const response = await fetch('/api/login') + const response = await fetch('/api/user/login') if (response.status !== 200) { throw Error(`Error: status code: ${response.status}`) } @@ -74,7 +74,7 @@ async function login() { }); const credential = createAuthenicationCredentialForServer(result); // finish login - const finishResponse = await fetch('/api/login', { + const finishResponse = await fetch('/api/user/login', { method: 'POST', headers: {"content-type": 'application/json'}, body: JSON.stringify(credential) @@ -94,7 +94,7 @@ async function login() { async function logout() { try { // initiate login - const response = await fetch('/api/logout') + const response = await fetch('/api/user/logout') if (response.status !== 200) { throw Error(`Error: status code: ${response.status}`) } @@ -109,7 +109,7 @@ async function logout() { */ async function test() { try { - const response = await fetch('/api/test') + const response = await fetch('/api/user/test') if (response.status !== 200) { throw Error(`Error: status code: ${response.status}`) } @@ -121,8 +121,8 @@ async function test() { } /** - * Convert server response from /api/beginregister to PublicKeyCredentialCreationOptions - * @param {*} response Server response from /api/beginregister + * Convert server response from /api/user/beginregister to PublicKeyCredentialCreationOptions + * @param {*} response Server response from /api/user/beginregister * @returns PublicKeyCredentialCreationOptions */ function createPublicKeyCredentialCreationOptionsFromServerResponse(response) { @@ -142,9 +142,9 @@ function createPublicKeyCredentialCreationOptionsFromServerResponse(response) { } /** - * Convert return value from navigator.credentials.create to input JSON for /api/finishregister + * Convert return value from navigator.credentials.create to input JSON for /api/user/finishregister * @param {*} registrationCredential Result of navigator.credentials.create - * @returns Input for /api/finishregister + * @returns Input for /api/user/finishregister */ function createRegistrationCredentialForServer(registrationCredential) { return { @@ -160,8 +160,8 @@ function createRegistrationCredentialForServer(registrationCredential) { } /** - * Convert return value from GET /api/login to PublicKeyCredentialRequestOptions - * @param {*} response Server response from GET /api/login + * Convert return value from GET /api/user/login to PublicKeyCredentialRequestOptions + * @param {*} response Server response from GET /api/user/login * @returns PublicKeyCredentialRequestOptions */ function createPublicKeyCredentialRequestOptionsFromServerResponse(response) { @@ -173,9 +173,9 @@ function createPublicKeyCredentialRequestOptionsFromServerResponse(response) { } /** - * Convert return value of navigator.credentials.get to input JSON for POST /api/login + * Convert return value of navigator.credentials.get to input JSON for POST /api/user/login * @param {*} credential Result of navigator.credentials.get - * @returns Input for POST /api/login + * @returns Input for POST /api/user/login */ function createAuthenicationCredentialForServer(credential) { return {