mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
BIT-387: Adds identity token response validation to catch captcha errors (#43)
This commit is contained in:
parent
2667b8004a
commit
8893580659
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Captcha required.",
|
||||
"HCaptcha_SiteKey": "1234"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
52
GlobalTestHelpers/Extensions/HTTPResponse.swift
Normal file
52
GlobalTestHelpers/Extensions/HTTPResponse.swift
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
enum TestError: Error, Equatable {
|
||||
case badResponse
|
||||
case invalidResponse
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user