Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
@@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Utility
|
||||
import WebAuthn
|
||||
|
||||
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
|
||||
///
|
||||
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
|
||||
/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has
|
||||
/// no other proof of who is asking, so that would hand the account over.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct PasskeyController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts and their passkeys are read and created through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
/// The ceremonies under way.
|
||||
private let ceremonies: PasskeyCeremonies
|
||||
|
||||
/// Whether the cookies are limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
||||
private let origin: String
|
||||
|
||||
/// The store sessions are started in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a passkey controller.
|
||||
/// - Parameters:
|
||||
/// - origin: the origin the sign-in screen is served at, e.g. `https://ccn.example`. Browsers bind a passkey to it, and an answer
|
||||
/// signed for any other origin is turned down — so it is configuration, never read off a request.
|
||||
/// - accounts: the repository accounts and their passkeys are read and created through.
|
||||
/// - sessions: the store sessions are started in.
|
||||
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
||||
public init(
|
||||
origin: String,
|
||||
accounts: AccountRepository,
|
||||
sessions: SessionStore,
|
||||
ceremonies: PasskeyCeremonies = .init()
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.ceremonies = ceremonies
|
||||
self.isSecure = origin.hasPrefix("https://")
|
||||
self.origin = origin
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension PasskeyController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/options"),
|
||||
use: options
|
||||
)
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/finish"),
|
||||
use: finish
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PasskeyController {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The WebAuthn relying party the ceremonies are run as. Built per request: the manager is not `Sendable`, and holds no state.
|
||||
var manager: WebAuthnManager {
|
||||
.init(configuration: .init(
|
||||
relyingPartyID: URLComponents(string: origin)?.host ?? "localhost",
|
||||
relyingPartyName: "CCN",
|
||||
relyingPartyOrigin: origin
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles the browser's signed answer: checks it against the ceremony it belongs to, then starts the session.
|
||||
@Sendable
|
||||
func finish(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard
|
||||
let token = request.cookies[PasskeyCeremonies.cookieName]?.value,
|
||||
let ceremony = await ceremonies.take(token)
|
||||
else {
|
||||
return Self.refusal(.badRequest, "That sign-in ran out. Try again.")
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: 64 * 1_024)
|
||||
let user: User
|
||||
|
||||
do {
|
||||
switch ceremony.kind {
|
||||
case .registration(let email):
|
||||
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
|
||||
let credential = try await manager.finishRegistration(
|
||||
challenge: ceremony.challenge,
|
||||
credentialCreationData: answer.credential,
|
||||
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||
}
|
||||
)
|
||||
|
||||
guard let joined = try await accounts.join(
|
||||
email: email,
|
||||
credentialID: Self.base64URL(credential.id),
|
||||
publicKey: credential.publicKey,
|
||||
signCount: credential.signCount
|
||||
) else {
|
||||
return Self.refusal(.conflict, "That email already has an account.")
|
||||
}
|
||||
|
||||
user = joined
|
||||
|
||||
return try await signedIn(user, returningTo: answer.return)
|
||||
case .authentication:
|
||||
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
||||
let credentialID = Self.base64URL(answer.credential.id.asString())
|
||||
|
||||
guard let passkey = try await accounts.passkey(credentialID: credentialID) else {
|
||||
return Self.refusal(.unauthorized, "That passkey isn't registered with CCN.")
|
||||
}
|
||||
|
||||
let verified = try manager.finishAuthentication(
|
||||
credential: answer.credential,
|
||||
expectedChallenge: ceremony.challenge,
|
||||
credentialPublicKey: passkey.publicKey,
|
||||
credentialCurrentSignCount: passkey.signCount
|
||||
)
|
||||
|
||||
try await accounts.recordUse(ofPasskey: credentialID, signCount: verified.newSignCount)
|
||||
|
||||
return try await signedIn(passkey.user, returningTo: answer.return)
|
||||
}
|
||||
} catch is DecodingError {
|
||||
return Self.refusal(.badRequest, "That answer couldn't be read.")
|
||||
} catch is WebAuthnError {
|
||||
return Self.refusal(.unauthorized, "That passkey couldn't be verified.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the start of a ceremony: issues the challenge, and tells the script whether to create a passkey or use one.
|
||||
@Sendable
|
||||
func options(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
// No email: whichever passkey the browser holds for this site.
|
||||
guard !typed.isEmpty else {
|
||||
return try await begin(.authentication, options: manager.beginAuthentication(), mode: "get")
|
||||
}
|
||||
|
||||
guard let email = NormalizeEmail()(typed) else {
|
||||
return Self.refusal(.unprocessableContent, "That doesn't look like an email address.")
|
||||
}
|
||||
|
||||
guard try await accounts.existingAccount(email: email) != nil else {
|
||||
let options = manager.beginRegistration(user: .init(
|
||||
id: Array(UUID().uuidString.utf8),
|
||||
name: email,
|
||||
displayName: String(email.prefix { $0 != "@" })
|
||||
))
|
||||
|
||||
return try await begin(.registration(email: email), options: options, challenge: options.challenge, mode: "create")
|
||||
}
|
||||
|
||||
let passkeys = try await accounts.passkeys(email: email)
|
||||
|
||||
guard !passkeys.isEmpty else {
|
||||
return Self.refusal(.conflict, "That account has no passkey. The prototype's demo accounts sign in with the demo sign-in below.")
|
||||
}
|
||||
|
||||
return try await begin(
|
||||
.authentication,
|
||||
options: manager.beginAuthentication(allowCredentials: passkeys.compactMap { passkey in
|
||||
Self.bytes(base64URL: passkey.credentialID).map { .init(type: .publicKey, id: $0) }
|
||||
}),
|
||||
mode: "get"
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts an authentication ceremony and answers with its options.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
try await begin(kind, options: options, challenge: options.challenge, mode: mode)
|
||||
}
|
||||
|
||||
/// Starts a ceremony and answers with its options, as `{"mode": …, "options": …}`, and the ceremony's cookie.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: some Encodable,
|
||||
challenge: [UInt8],
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
let token = await ceremonies.start(kind, challenge: challenge)
|
||||
let options = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"mode":"\#(mode)","options":\#(options)}"#)
|
||||
|
||||
response.setCookie(PasskeyCeremonies.cookie(token, isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// Starts the session of a signed-in account, and answers with where to go next.
|
||||
func signedIn(
|
||||
_ user: User,
|
||||
returningTo path: String?
|
||||
) async throws -> Response {
|
||||
let token = await sessions.start(for: user)
|
||||
let location = String(decoding: try JSONEncoder().encode(String.Route.safeReturn(path)), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"location":\#(location)}"#)
|
||||
|
||||
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// A base64 or base64url string as base64url without padding: the one form credential identifiers are kept and compared in.
|
||||
static func base64URL(
|
||||
_ string: String
|
||||
) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
/// The bytes a base64url string encodes, or `nil` when it encodes none.
|
||||
static func bytes(
|
||||
base64URL string: String
|
||||
) -> [UInt8]? {
|
||||
var base64 = string
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
|
||||
return Data(base64Encoded: base64).map(Array.init)
|
||||
}
|
||||
|
||||
/// A JSON response, never stored.
|
||||
static func json(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ body: String
|
||||
) -> Response {
|
||||
.init(
|
||||
status: status,
|
||||
headers: [
|
||||
.contentType: "application/json; charset=utf-8",
|
||||
.cacheControl: "no-store",
|
||||
],
|
||||
body: .init(byteBuffer: .init(string: body))
|
||||
)
|
||||
}
|
||||
|
||||
/// A refusal, as `{"error": …}`, for the script to show.
|
||||
static func refusal(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ message: String
|
||||
) -> Response {
|
||||
let message = (try? JSONEncoder().encode(message)).map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
|
||||
|
||||
return json(status, #"{"error":\#(message)}"#)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// What the script posts to start a ceremony.
|
||||
private struct Start: Decodable {
|
||||
let email: String?
|
||||
}
|
||||
|
||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||
private struct Answer<Credential: Decodable>: Decodable {
|
||||
let credential: Credential
|
||||
let `return`: String?
|
||||
}
|
||||
Reference in new issue
Block a user