2024-06-19 15:06:39 +00:00
|
|
|
// RFC - 6120: chapter 6
|
|
|
|
// XEP-0388: Extensible SASL Profile
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
enum AuthorizationStep: Codable {
|
|
|
|
case notAuthorized
|
|
|
|
case inProgress
|
|
|
|
case authorized
|
|
|
|
}
|
|
|
|
|
|
|
|
enum AuthorizationError: Error {
|
|
|
|
case noSupportedMechanisms
|
|
|
|
case channelBindError
|
|
|
|
case mechanismError(String)
|
|
|
|
}
|
|
|
|
|
|
|
|
final class AuthorizationModule: XmppModule {
|
|
|
|
let id = "Authorization module"
|
|
|
|
|
2024-12-17 08:34:44 +00:00
|
|
|
private weak var storage: (any XMPPStorage)?
|
2024-06-19 15:06:39 +00:00
|
|
|
private var mechanism: AuthorizationMechanism?
|
|
|
|
|
2024-12-17 08:34:44 +00:00
|
|
|
init(_ storage: any XMPPStorage) {
|
2024-06-19 15:06:39 +00:00
|
|
|
self.storage = storage
|
|
|
|
}
|
|
|
|
|
|
|
|
func reduce(oldState: ClientState, with event: Event) -> ClientState {
|
|
|
|
var newState = oldState
|
|
|
|
switch event {
|
|
|
|
case .startAuth:
|
|
|
|
newState.authorizationStep = .inProgress
|
|
|
|
|
|
|
|
case .gotAuthError:
|
|
|
|
newState.authorizationStep = .notAuthorized
|
|
|
|
|
|
|
|
case .authDone:
|
|
|
|
newState.authorizationStep = .authorized
|
|
|
|
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return newState
|
|
|
|
}
|
|
|
|
|
|
|
|
func process(state: ClientState, with event: Event) async -> Event? {
|
|
|
|
switch event {
|
|
|
|
case .xmlInbound(let xml):
|
|
|
|
guard state.isSocketSecured else { return nil }
|
|
|
|
switch (xml.name, state.authorizationStep) {
|
|
|
|
case ("stream:features", .notAuthorized):
|
|
|
|
let credentials = await storage?.getCredentialsByUUID(state.credentialsId)
|
|
|
|
return await selectBestAuthMechanism(xml, state.allowPlainAuth, state, credentials)
|
|
|
|
|
|
|
|
case (_, .inProgress):
|
|
|
|
return .challengeAuth(xml)
|
|
|
|
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
case .startAuth(let xml):
|
|
|
|
return .xmlOutbound(xml)
|
|
|
|
|
|
|
|
case .challengeAuth(let xml):
|
|
|
|
return await mechanism?.challenge(xml: xml)
|
|
|
|
|
|
|
|
case .authDone:
|
|
|
|
mechanism = nil
|
|
|
|
return nil
|
|
|
|
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private extension AuthorizationModule {
|
|
|
|
var supportedChannelBindings: [String] {
|
|
|
|
["tls-exporter", "tls-server-end-point"]
|
|
|
|
}
|
|
|
|
|
2024-12-17 08:34:44 +00:00
|
|
|
func selectBestAuthMechanism(_ xml: XMLElement, _ isPlainAllowed: Bool, _ state: ClientState, _ creds: Credentials?) async -> Event {
|
2024-06-19 15:06:39 +00:00
|
|
|
await withCheckedContinuation { continuation in
|
|
|
|
var sasl1: [AuthorizationMechanismType] = []
|
|
|
|
var channelBindings: [String] = []
|
|
|
|
var sasl2: [AuthorizationMechanismType] = []
|
|
|
|
var inlines: XMLElement?
|
|
|
|
|
|
|
|
// parse features
|
|
|
|
for element in xml.nodes {
|
|
|
|
// extract sasl1 mechanisms
|
|
|
|
if element.name == "mechanisms" && element.xmlns == "urn:ietf:params:xml:ns:xmpp-sasl" {
|
|
|
|
sasl1 = element.nodes
|
|
|
|
.compactMap { AuthorizationMechanismType(rawValue: $0.content ?? "") }
|
|
|
|
.sorted { $0.priority < $1.priority }
|
|
|
|
}
|
|
|
|
// extract channel bindings
|
|
|
|
if element.name == "sasl-channel-binding" && element.xmlns == "urn:xmpp:sasl-cb:0" {
|
|
|
|
channelBindings = element.nodes
|
|
|
|
.compactMap { $0.attributes["type"] }
|
|
|
|
.filter { supportedChannelBindings.contains($0) }
|
|
|
|
}
|
|
|
|
// extract sasl2
|
|
|
|
if element.name == "authentication" && element.xmlns == "urn:xmpp:sasl:2" {
|
|
|
|
// sasl2 mechanisms
|
|
|
|
sasl2 = element.nodes
|
|
|
|
.filter { $0.name == "mechanism" && $0.xmlns == "urn:xmpp:sasl:2" }
|
|
|
|
.compactMap { AuthorizationMechanismType(rawValue: $0.content ?? "") }
|
|
|
|
.sorted { $0.priority < $1.priority }
|
|
|
|
|
|
|
|
// sasl2 inlines
|
|
|
|
inlines = element.nodes.first(where: { $0.name == "inline" && $0.xmlns == "urn:xmpp:sasl:2" })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// filter out PLAIN if needed
|
|
|
|
if !isPlainAllowed {
|
|
|
|
sasl1 = sasl1.filter { $0 != .plain }
|
|
|
|
sasl2 = sasl2.filter { $0 != .plain }
|
|
|
|
}
|
|
|
|
if sasl1.isEmpty && sasl2.isEmpty {
|
|
|
|
continuation.resume(returning: .gotAuthError(.noSupportedMechanisms))
|
|
|
|
}
|
|
|
|
|
|
|
|
// select best authorization way
|
|
|
|
var best = sasl1.map { ($0, SaslType.sasl1) }
|
|
|
|
for mechanism in sasl2 {
|
|
|
|
if best.isEmpty {
|
|
|
|
best.insert((mechanism, SaslType.sasl2), at: 0)
|
|
|
|
} else if mechanism.priority <= best[0].0.priority {
|
|
|
|
best.insert((mechanism, SaslType.sasl2), at: 0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let selected = best[0]
|
|
|
|
|
|
|
|
// init mechanism and start auth
|
|
|
|
mechanism = AuthorizationMechanismImpl(
|
|
|
|
type: selected.0,
|
|
|
|
jid: state.jid,
|
|
|
|
credentials: creds,
|
|
|
|
saslType: selected.1,
|
|
|
|
userAgent: state.userAgent,
|
|
|
|
channelBind: nil, // TODO: check channel binding and implement *-PLUS mechanisms
|
|
|
|
inlines: nil // TODO: Implement inlines
|
|
|
|
)
|
|
|
|
let request = mechanism?.initRequest
|
|
|
|
if let request {
|
|
|
|
continuation.resume(returning: .startAuth(request))
|
|
|
|
} else {
|
|
|
|
continuation.resume(returning: .gotAuthError(.mechanismError("Init request is empty")))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|