Files
bundesmessenger-ios/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift
Doug 9c5aa0ca1e Add Email/Terms/ReCaptcha into the Authentication flow
Replace ReCaptcha navigation delegate with a WKUserContentController.
Move callback property closures onto the MainActor.
Show a loading indicator whilst waiting for the authentication service to start.
Move nextUncompletedStage into FlowResult.
Handle text field actions during authentication.
Remove scroll view tweaks in server selection screen following EMS banner removal.
2022-05-19 11:43:38 +01:00

291 lines
14 KiB
Swift

//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// Set of methods to be able to create an account on a homeserver.
///
/// Common scenario to register an account successfully:
/// - Call `registrationFlow` to check that you application supports all the mandatory registration stages
/// - Call `createAccount` to start the account creation
/// - Fulfil all mandatory stages using the methods `performReCaptcha` `acceptTerms` `dummy`, etc.
///
/// More documentation can be found in the file https://github.com/vector-im/element-android/blob/main/docs/signup.md
/// and https://matrix.org/docs/spec/client_server/latest#account-registration-and-management
class RegistrationWizard {
struct State {
var currentSession: String?
var isRegistrationStarted = false
var currentThreePIDData: ThreePIDData?
var clientSecret = UUID().uuidString
var sendAttempt: UInt = 0
}
let client: MXRestClient
let sessionCreator: SessionCreator
private(set) var state: State
/// This is the current ThreePID, waiting for validation. The SDK will store it in database, so it can be
/// restored even if the app has been killed during the registration
var currentThreePID: String? {
guard let threePid = state.currentThreePIDData?.threePID else { return nil }
switch threePid {
case .email(let string):
return string
case .msisdn(let msisdn, _):
return state.currentThreePIDData?.registrationResponse.formattedMSISDN ?? msisdn
}
}
/// True when login and password have been sent with success to the homeserver,
/// i.e. `createAccount` has been called successfully.
var isRegistrationStarted: Bool {
state.isRegistrationStarted
}
init(client: MXRestClient, sessionCreator: SessionCreator = SessionCreator()) {
self.client = client
self.sessionCreator = sessionCreator
self.state = State()
}
/// Call this method to get the possible registration flow of the current homeserver.
/// It can be useful to ensure that your application implementation supports all the stages
/// required to create an account. If it is not the case, you will have to use the web fallback
/// to let the user create an account with your application.
/// See `AuthenticationService.getFallbackUrl`
func registrationFlow() async throws -> RegistrationResult {
let parameters = RegistrationParameters()
do {
let result = try await performRegistrationRequest(parameters: parameters)
return result
} catch {
// Map M_FORBIDDEN into a registration error.
guard let mxError = MXError(nsError: error), mxError.errcode == kMXErrCodeStringForbidden else { throw error }
MXLog.warning("[RegistrationWizard] Registration is disabled for the selected server.")
throw RegistrationError.registrationDisabled
}
}
/// Can be call to check is the desired username is available for registration on the current homeserver.
/// It may also fails if the desired username is not correctly formatted or does not follow any restriction on
/// the homeserver. Ex: username with only digits may be rejected.
/// - Parameter username the desired username. Ex: "alice"
func registrationAvailable(username: String) async throws -> Bool {
try await client.isUsernameAvailable(username)
}
/// This is the first method to call in order to create an account and start the registration process.
///
/// - Parameter username the desired username. Ex: "alice"
/// - Parameter password the desired password
/// - Parameter initialDeviceDisplayName the device display name
func createAccount(username: String?,
password: String?,
initialDeviceDisplayName: String?) async throws -> RegistrationResult {
let parameters = RegistrationParameters(username: username, password: password, initialDeviceDisplayName: initialDeviceDisplayName)
let result = try await performRegistrationRequest(parameters: parameters, isCreatingAccount: true)
state.isRegistrationStarted = true
return result
}
/// Perform the "m.login.recaptcha" stage.
///
/// - Parameter response: The response from ReCaptcha
func performReCaptcha(response: String) async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] performReCaptcha: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
let parameters = RegistrationParameters(auth: AuthenticationParameters.captchaParameters(session: session, captchaResponse: response))
return try await performRegistrationRequest(parameters: parameters)
}
/// Perform the "m.login.terms" stage.
func acceptTerms() async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] acceptTerms: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
let parameters = RegistrationParameters(auth: AuthenticationParameters(type: kMXLoginFlowTypeTerms, session: session))
return try await performRegistrationRequest(parameters: parameters)
}
/// Perform the "m.login.dummy" stage.
func dummy() async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] dummy: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
let parameters = RegistrationParameters(auth: AuthenticationParameters(type: kMXLoginFlowTypeDummy, session: session))
return try await performRegistrationRequest(parameters: parameters)
}
/// Perform the "m.login.email.identity" or "m.login.msisdn" stage.
///
/// - Parameter threePID: the threePID to add to the account. If this is an email, the homeserver will send an email
/// to validate it. For a msisdn a SMS will be sent.
func addThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
state.currentThreePIDData = nil
return try await sendThreePID(threePID: threePID)
}
/// Ask the homeserver to send again the current threePID (email or msisdn).
func sendAgainThreePID() async throws -> RegistrationResult {
guard let threePID = state.currentThreePIDData?.threePID else {
MXLog.error("[RegistrationWizard] sendAgainThreePID: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
return try await sendThreePID(threePID: threePID)
}
/// Send the code received by SMS to validate a msisdn.
/// If the code is correct, the registration request will be executed to validate the msisdn.
func handleValidateThreePID(code: String) async throws -> RegistrationResult {
return try await validateThreePid(code: code)
}
/// Useful to poll the homeserver when waiting for the email to be validated by the user.
/// Once the email is validated, this method will return successfully.
func checkIfEmailHasBeenValidated() async throws -> RegistrationResult {
guard let parameters = state.currentThreePIDData?.registrationParameters else {
MXLog.error("[RegistrationWizard] checkIfEmailHasBeenValidated: The current 3pid data hasn't been stored in the state.")
throw RegistrationError.missingThreePIDData
}
do {
return try await performRegistrationRequest(parameters: parameters)
} catch {
// An unauthorized error indicates that the user hasn't tapped the link yet.
guard isUnauthorized(error) else { throw error }
throw RegistrationError.waitingForThreePIDValidation
}
}
// MARK: - Private
private func validateThreePid(code: String) async throws -> RegistrationResult {
guard let threePIDData = state.currentThreePIDData else {
MXLog.error("[RegistrationWizard] validateThreePid: There is no third party ID data stored in the state.")
throw RegistrationError.missingThreePIDData
}
guard let submitURL = threePIDData.registrationResponse.submitURL else {
MXLog.error("[RegistrationWizard] validateThreePid: The third party ID data doesn't contain a submitURL.")
throw RegistrationError.missingThreePIDURL
}
let validationBody = ThreePIDValidationCodeBody(clientSecret: state.clientSecret,
sessionID: threePIDData.registrationResponse.sessionID,
code: code)
#warning("Seems odd to pass a nil baseURL and then the url as the path, yet this is how MXK3PID works")
guard let httpClient = MXHTTPClient(baseURL: nil, andOnUnrecognizedCertificateBlock: nil) else {
MXLog.error("[RegistrationWizard] validateThreePid: Failed to create an MXHTTPClient.")
throw RegistrationError.threePIDClientFailure
}
guard try await httpClient.validateThreePIDCode(submitURL: submitURL, validationBody: validationBody) else {
MXLog.error("[RegistrationWizard] validateThreePid: Third party ID validation failed.")
throw RegistrationError.threePIDValidationFailure
}
let parameters = threePIDData.registrationParameters
MXLog.failure("This method used to add a 3-second delay to the request. This should be moved to the caller of `handleValidateThreePID`.")
return try await performRegistrationRequest(parameters: parameters)
}
private func sendThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult {
guard let session = state.currentSession else {
MXLog.error("[RegistrationWizard] sendThreePID: Missing authentication session, createAccount hasn't been called.")
throw RegistrationError.createAccountNotCalled
}
let response = try await client.requestTokenDuringRegistration(for: threePID,
clientSecret: state.clientSecret,
sendAttempt: state.sendAttempt)
state.sendAttempt += 1
let threePIDCredentials = ThreePIDCredentials(clientSecret: state.clientSecret, sessionID: response.sessionID)
let authenticationParameters: AuthenticationParameters
switch threePID {
case .email:
authenticationParameters = AuthenticationParameters.emailIdentityParameters(session: session, threePIDCredentials: threePIDCredentials)
case .msisdn:
authenticationParameters = AuthenticationParameters.msisdnIdentityParameters(session: session, threePIDCredentials: threePIDCredentials)
}
let parameters = RegistrationParameters(auth: authenticationParameters)
state.currentThreePIDData = ThreePIDData(threePID: threePID, registrationResponse: response, registrationParameters: parameters)
do {
// Send the session id for the first time
return try await performRegistrationRequest(parameters: parameters)
} catch {
// An unauthorized error means that it was accepted and is awaiting validation.
guard isUnauthorized(error) else { throw error }
throw RegistrationError.waitingForThreePIDValidation
}
}
private func performRegistrationRequest(parameters: RegistrationParameters, isCreatingAccount: Bool = false) async throws -> RegistrationResult {
do {
let response = try await client.register(parameters: parameters)
let credentials = MXCredentials(loginResponse: response, andDefaultCredentials: client.credentials)
return .success(sessionCreator.createSession(credentials: credentials, client: client))
} catch {
let nsError = error as NSError
guard
let jsonResponse = nsError.userInfo[MXHTTPClientErrorResponseDataKey] as? [String: Any],
let authenticationSession = MXAuthenticationSession(fromJSON: jsonResponse)
else { throw error }
state.currentSession = authenticationSession.session
let flowResult = authenticationSession.flowResult
if isCreatingAccount || isRegistrationStarted {
return try await handleMandatoryDummyStage(flowResult: flowResult)
}
return .flowResponse(flowResult)
}
}
/// Checks for a mandatory dummy stage and handles it automatically when possible.
private func handleMandatoryDummyStage(flowResult: FlowResult) async throws -> RegistrationResult {
// If the dummy stage is mandatory, do the dummy stage now
guard flowResult.missingStages.contains(where: { $0.isDummy && $0.isMandatory }) else { return .flowResponse(flowResult) }
return try await dummy()
}
/// Checks whether an error is an `M_UNAUTHORIZED` for handling third party ID responses.
private func isUnauthorized(_ error: Error) -> Bool {
guard let mxError = MXError(nsError: error) else { return false }
return mxError.errcode == kMXErrCodeStringUnauthorized
}
}