another.im-ios/AnotherXMPP/modules/auth/AuthorizationModule.swift

155 lines
5.3 KiB
Swift
Raw Normal View History

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")))
}
}
}
}