BIT-387: Adds identity token response validation to catch captcha errors (#43)

This commit is contained in:
Nathan Ansel 2023-09-26 20:05:55 -05:00 committed by GitHub
parent 2667b8004a
commit 8893580659
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 201 additions and 12 deletions

View File

@ -63,4 +63,23 @@ class AuthAPIServiceTests: BitwardenTestCase {
)
)
}
/// `getIdentityToken()` throws a `.captchaRequired` error when a `400` http response with the correct data
/// is returned.
func test_getIdentityToken_captchaError() async throws {
client.result = .httpFailure(
statusCode: 400,
data: APITestData.identityTokenCaptchaError.data
)
await assertAsyncThrows(error: IdentityTokenRequestError.captchaRequired(hCaptchaSiteCode: "1234")) {
_ = try await subject.getIdentityToken(
IdentityTokenRequestModel(
authenticationMethod: .password(username: "username", password: "password"),
captchaToken: nil,
deviceInfo: .fixture()
)
)
}
}
}

View File

@ -1,3 +1,6 @@
extension APITestData {
static let identityToken = loadFromJsonBundle(resource: "identityToken")
// MARK: Identity Token
static let identityToken = loadFromJsonBundle(resource: "IdentityTokenSuccess")
static let identityTokenCaptchaError = loadFromJsonBundle(resource: "IdentityTokenCaptchaFailure")
}

View File

@ -0,0 +1,5 @@
{
"error": "invalid_grant",
"error_description": "Captcha required.",
"HCaptcha_SiteKey": "1234"
}

View File

@ -1,9 +1,23 @@
import Foundation
import Networking
// MARK: - IdentityTokenRequestError
/// Errors that can occur when sending an `IdentityTokenRequest`.
enum IdentityTokenRequestError: Error, Equatable {
/// Captcha is required for this login attempt.
///
/// - Parameter hCaptchaSiteCode: The site code to use when authenticating with hCaptcha.
case captchaRequired(hCaptchaSiteCode: String)
}
// MARK: - IdentityTokenRequest
/// Data model for performing a identity token request.
///
struct IdentityTokenRequest: Request {
// MARK: Types
typealias Response = IdentityTokenResponseModel
// MARK: Properties
@ -39,4 +53,21 @@ struct IdentityTokenRequest: Request {
init(requestModel: IdentityTokenRequestModel) {
self.requestModel = requestModel
}
// MARK: Methods
func validate(_ response: HTTPResponse) throws {
switch response.statusCode {
case 400:
guard let object = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any],
let siteCode = object["HCaptcha_SiteKey"] as? String
else { return }
// Only throw the captcha error if the captcha site key can be found. Otherwise, this must be
// some other type of error.
throw IdentityTokenRequestError.captchaRequired(hCaptchaSiteCode: siteCode)
default:
return
}
}
}

View File

@ -1,3 +1,4 @@
import Networking
import XCTest
@testable import BitwardenShared
@ -92,4 +93,36 @@ class IdentityTokenRequestTests: BitwardenTestCase {
XCTAssertTrue(subjectAuthorizationCode.query.isEmpty)
XCTAssertTrue(subjectPassword.query.isEmpty)
}
/// `validate(_:)` with a `400` status code and captcha error in the response body throws a `.captchaRequired`
/// error.
func test_validate_with400CaptchaError() {
let response = HTTPResponse.failure(
statusCode: 400,
body: APITestData.identityTokenCaptchaError.data
)
XCTAssertThrowsError(try subjectAuthorizationCode.validate(response)) { error in
XCTAssertEqual(error as? IdentityTokenRequestError, .captchaRequired(hCaptchaSiteCode: "1234"))
}
}
/// `validate(_:)` with a `400` status code but no captcha error does not throw a validation error.
func test_validate_with400NonCaptchaError() {
let response = HTTPResponse.failure(
statusCode: 400,
body: Data("example data".utf8)
)
XCTAssertNoThrow(try subjectAuthorizationCode.validate(response))
}
/// `validate(_:)` with a valid response does not throw a validation error.
func test_validate_with200() {
let response = HTTPResponse.success(
body: APITestData.identityToken.data
)
XCTAssertNoThrow(try subjectAuthorizationCode.validate(response))
}
}

View File

@ -3,12 +3,8 @@ import Networking
extension Result where Success == HTTPResponse, Error: Error {
static func httpSuccess(testData: APITestData) -> Result<HTTPResponse, Error> {
let response = HTTPResponse(
url: URL(string: "https://example.com")!,
statusCode: 200,
headers: [:],
body: testData.data,
requestID: UUID()
let response = HTTPResponse.success(
body: testData.data
)
return .success(response)
}
@ -18,12 +14,10 @@ extension Result where Success == HTTPResponse, Error: Error {
headers: [String: String] = [:],
data: Data = Data()
) -> Result<HTTPResponse, Error> {
let response = HTTPResponse(
url: URL(string: "https://example.com")!,
let response = HTTPResponse.failure(
statusCode: statusCode,
headers: headers,
body: data,
requestID: UUID()
body: data
)
return .success(response)
}

View File

@ -67,7 +67,7 @@ class LoginProcessor: StateProcessor<LoginState, LoginAction, LoginEffect> {
///
private func loginWithMasterPassword() async {
do {
let response = try await services.accountAPIService.preLogin(email: state.username)
_ = try await services.accountAPIService.preLogin(email: state.username)
coordinator.navigate(to: .complete)
// Encrypt the password with the kdf algorithm and send it to the server for verification: BIT-420
} catch {

View File

@ -0,0 +1,52 @@
import Foundation
import Networking
extension HTTPResponse {
/// Creates a successful `HTTPResponse` for testing purposes.
///
/// - Parameters:
/// - string: A string version of the `URL` for this response. Defaults to `http://example.com`.
/// - statusCode: The status code for this response. Defaults to `200`.
/// - headers: The headers for this response. Defaults to `[:]`.
/// - body: The body for this response. Defaults to an empty `Data` object.
/// - Returns: Returns a `HTTPResponse` with the parameters provided.
///
static func success(
string: String = "http://example.com",
statusCode: Int = 200,
headers: [String: String] = [:],
body: Data = Data()
) -> HTTPResponse {
HTTPResponse(
url: URL(string: string)!,
statusCode: statusCode,
headers: headers,
body: body,
requestID: UUID()
)
}
/// Creates a failure `HTTPResponse` for testing purposes.
///
/// - Parameters:
/// - string: A string version of the `URL` for this response. Defaults to `http://example.com`.
/// - statusCode: The status code for this response. Defaults to `500`.
/// - headers: The headers for this response. Defaults to `[:]`.
/// - body: The body for this response. Defaults to an empty `Data` object.
/// - Returns: Returns a `HTTPResponse` with the parameters provided.
///
static func failure(
string: String = "http://example.com",
statusCode: Int = 500,
headers: [String: String] = [:],
body: Data = Data()
) -> HTTPResponse {
HTTPResponse(
url: URL(string: string)!,
statusCode: statusCode,
headers: headers,
body: body,
requestID: UUID()
)
}
}

View File

@ -66,6 +66,9 @@ public class HTTPService {
var httpResponse = try await client.send(httpRequest)
logger.logResponse(httpResponse)
try request.validate(httpResponse)
for handler in responseHandlers {
httpResponse = try await handler.handle(&httpResponse)
}

View File

@ -1,8 +1,12 @@
import Foundation
// MARK: - Request
/// A protocol for an instance that describes an HTTP request.
///
public protocol Request {
// MARK: Types
/// The response type associated with this request.
associatedtype Response
@ -10,6 +14,8 @@ public protocol Request {
/// to `RequestBody` that could be converted to `Data` to include in the body of the request.
associatedtype Body: RequestBody
// MARK: Properties
/// The HTTP method for the request.
var method: HTTPMethod { get }
@ -28,6 +34,18 @@ public protocol Request {
/// A list of URL query items for the request.
var query: [URLQueryItem] { get }
// MARK: Methods
/// Validates the response for this request.
///
/// For example, this method can be used to catch status code errors, or errors that are contained within
/// the response's `body`.
///
/// - Parameter response: The `HTTPResponse` that should be validated for this request.
/// - Throws: Throws an error if validation fails.
///
func validate(_ response: HTTPResponse) throws
}
/// This extension provides default values for the `Request` methods, which can be overridden in a
@ -45,4 +63,12 @@ public extension Request {
/// A list of URL query items for the request.
var query: [URLQueryItem] { [] }
/// Validates the response for this request.
///
/// - Parameter response: The `HTTPResponse` that should be validated for this request.
/// - Returns: The validated `HTTPResponse`.
/// - Throws: Throws an error if validation fails.
///
func validate(_ response: HTTPResponse) throws {}
}

View File

@ -105,6 +105,19 @@ class HTTPServiceTests: XCTestCase {
XCTAssertTrue(error is RequestError)
}
}
/// `send(_:)` throws the error encountered when validating the response.
func test_sendRequest_validatesResponse() async {
let response = HTTPResponse.failure(statusCode: 400)
client.result = .success(response)
do {
_ = try await subject.send(TestRequest())
XCTFail("Expected send(_:) to throw an error")
} catch {
XCTAssertEqual(error as? TestError, .invalidResponse)
}
}
}
private struct RequestError: Error {}

View File

@ -1,3 +1,4 @@
enum TestError: Error, Equatable {
case badResponse
case invalidResponse
}

View File

@ -5,6 +5,15 @@ import Foundation
struct TestRequest: Request {
typealias Response = TestResponse
let path = "/test"
func validate(_ response: HTTPResponse) throws {
switch response.statusCode {
case 400:
throw TestError.invalidResponse
default:
return
}
}
}
struct TestResponse: Response {