본문 바로가기

Swift

보안 핀코드 입력화면 제작기

핀테크팀 사업부로 개발을 진행하며 작업하였던 내용 중 유익한 내용을 정리하도록 하겠습니다.

 

우선 본인임을 증명하기 위한 인증 과정 중, 핀코드 인증화면을 작업했던 내용을 공유해볼까 합니다.

 

우선, 작업 완료한 결과물을 보여드리겠습니다.

 

 

요구사항

작업하기에 앞서 보안 키패드 제작에 있어 요구사항은 크게 3가지였습니다.

- 입력 창이 노출될 때 마다, 새로운 배열로 노출

- 등록 제한 번호 적용

- 입력한 핀번호 다중 암호화 및 서버 인증 시 전자서명 적용

 

다음 세 가지 요구사항을 충족하기 위해 적용한 결과를 순차적으로 설명드리겠습니다.

 

 

0. 구조설계

해당 기능을 설명드리기 앞서, 저는 이번 프로젝트에 RxSwiftReactorKit 프레임워크를 사용하고 있습니다.

 

기능을 구현하기에 앞서 요구사항들을 충족하기 위한 설계를 진행하였습니다.

 

간략히 설명을 드리자면,

 

번호를 입력하기 위한 KeypadViewController와 KeypadReactor, 입력한 이벤트 처리를 위한 KeypadService를 제작하였습니다.

 

enum KeypadEvent {
    case reachedMax(input: String)
}

protocol KeypadServiceProtocol {
    var maxCount: Int { get }
    var eventRelay: PublishRelay<KeypadEvent> { get }
    var inputRelay: BehaviorRelay<String> { get }
    func makeInput(input: PinInput)
}

class KeypadService: KeypadServiceProtocol {
    let maxCount: Int
    var inputRelay: BehaviorRelay<String>
    var eventRelay: PublishRelay<KeypadEvent>
    
    init(maxCount: Int) {
        self.maxCount = maxCount
        self.inputRelay = .init(value: String.empty)
        self.eventRelay = PublishRelay<KeypadEvent>()
    }
    
    func makeInput(input: PinInput) {
        switch input {
        case .delete:
            let currentInput = inputRelay.value
            guard currentInput.count > 0 else { return } 
            var deleted = Array(currentInput)
            deleted.remove(at: currentInput.count - 1)
            inputRelay.accept(String(deleted))
            
        case .number(let number):
            let currentInput = inputRelay.value
            guard currentInput.count < maxCount else { return }
            let appended = currentInput + number
            inputRelay.accept(appended)

            if inputRelay.value.count == self.maxCount {
                eventRelay.accept(.reachedMax(input: inputRelay.value))
            }
            
        case .clear:
            inputRelay.accept(String.empty)
        }
    }
}
enum PinInput {
    case delete
    case number(number: String)
    case clear
}

 

다음과 같이 KeypadService는

 

입력된 핀코드를 보관하는 inputRelay,

 

방출할 event를 보관하는 eventRelay,

 

Keypad를 통해 위 relay들을 갱신해주는 메서드로 이루어져 있습니다.

 

핀코드 인증 또는 설정을 위한 부모 ViewController (PincodeAuthViewController / PincodeUpdateViewController)는

 

KeypadViewController와 동일한 주소의 KeypadService를 바라보고 있으며, 

 

KeypadViewController에서 KeypadService를 통해 이벤트를 방출 시,

 

부모 ViewController의 Reactor에서 transform 메서드를 통해 해당 이벤트를 처리하는 방식으로 설계하였습니다.

 

자세한 설명을 하단 코드를 통해 설명드리겠습니다.

 

1. 랜덤 배열 키패드

첫번째 요구사항인 랜덤 배열 키패드입니다.

 

우선, 키패드 생성 시 스택뷰를 사용하였으며

 

세로 스택뷰안에 3자리 넘버를 갖고 있는 가로 스택뷰를 넣는 형태로,

 

3 x 4 배열에 랜덤한 숫자를 넣는 방식으로 진행하였습니다. 

 

여기서 유의해야 할 점이,

 

모든 숫자들이 랜덤으로 배치되어야 함과 동시에

 

최하단 첫번째 칸은 비어있어야 했으며,

 

최하단 마지막 칸은 backspace 버튼이 항상 노출되어야 했습니다.

 

따라서, 하단 이미지와 같이 0~11의 숫자들 중 10 과 11은 항상 동일한 위치에 고정시키는 배열을 생성하였습니다.

 

 

class func generatePincodeRandomNumber() -> [[Int]] {
	let BUTTON_NUMBER_SET: [Int] = Array(0...9)
        
	let shuffledNumbers = BUTTON_NUMBER_SET.shuffled()
	var resultNumberset: [[Int]] = []
	var tempNumberset: [Int] = []
	for index in 0..<shuffledNumbers.count {
		if((index != 0) && (index % 3 == 0)) {
        	resultNumberset.append(tempNumberset)
            tempNumberset.removeAll()
        }
        tempNumberset.append(shuffledNumbers[index])
        if index == shuffledNumbers.endIndex - 1 {
           resultNumberset.append(tempNumberset)
        }
    }
    resultNumberset[3].insert(10, at: 0)
    resultNumberset[3].append(11)
    return resultNumberset
}

 

위 함수를 통해 생성된 2차원 배열을 통해 각 칸에 배치될 숫자를 정의하였습니다.

 

다음으로, 10과 11이 위치된 경우 인풋으로 받아드리는 것이 아닌 다른 액션을 방출하도록 설정하겠습니다.

 

KeypadViewController 중 [UIButton]과 각 버튼의 액션을 정의해주는 코드를 살펴보겠습니다.

 

private func createStackViewButtions(_ names: [String]) -> [UIButton] {
        let buttons = names.indices.map { [weak self] index -> UIButton in
        guard let self = self else { return UIButton() }
        let button = UIButton()

        // uncomment in order to show selection effect
        button.withSelectionColor(disposeBag: self.disposeBag,
                                  selectedColor: .appColor(.lightMiraeAssetBlue))
        button.layer.cornerRadius = 10
            
        switch names[index] {
        case "10":
            button.backgroundColor = .appColor(.miraeAssetBlue)

        case "11":
            button.backgroundColor = .appColor(.miraeAssetBlue)
            button.adjustsImageWhenHighlighted = false
            button.setImage("backspace".asImage, for: .normal)
                
            button.rx
                .tapGesture()
                .when(.recognized)
                .do(onNext: { _ in
                    HapticManager.shared.playImpactFeedback(style: .medium)
                })
                .map { _ in
                    Reactor.Action.makeInput(input: .delete)
                }
                .bind(to: self.reactor!.action)
                .disposed(by: self.disposeBag)

        default:
            button.titleLabel?.font = .resizedFont(size: 24, font: .gothamMedium)
            button.backgroundColor = .appColor(.miraeAssetBlue)
            button.setTitleColor(.systemBackground, for: .normal)
            button.setTitle(names[index], for: .normal)
                
            button.rx
                .tapGesture()
                .when(.recognized)
                .do(onNext: { _ in
                    HapticManager.shared.playImpactFeedback(style: .medium)
                })
                .map { _ in
                    Reactor.Action.makeInput(input: .number(number: names[index]))
                }
                .bind(to: self.reactor!.action)
                .disposed(by: self.disposeBag)
        }

		return button
    }        
    return buttons
}

 

생성된 배열에 따라 버튼을 생성해주는 코드입니다.

 

다음과 같이 10의 경우 리액터에게 아무런 Action을 넘겨 주지 않으며,

 

11의 경우 PinInput의 delete 타입의 이벤트를 넘겨주게 됩니다.

 

10과 11을 제외한 나머지 숫자의 경우 number 타입과 함계 associatedValue로 실제 선택된 값을 넘겨줍니다.

 

2. 등록 제한 번호

두번째 요구 사항인 등록 제한 번호입니다.

 

등록 제한 번호의 기준은,

 

1. 연속된 숫자

2. 계단식으로 증가/감소하는 숫자

3. 사용자의 휴대폰 번호를 포함

4. 사용자의 생년월일과 동일

입니다.

 

하단 함수의 리턴값인 Observable<CommonValidation> 중 CommonValidation은

 

코드 중 입력값 확인을 위해 공통으로 사용하는 열거형 타입이며,

 

요청한 입력값이 유효한 경우 VALID / 유효하지 않은 경우 INVALID를 리턴합니다.

 

class func validatePinCode(input: String,
                           birthDay: String,
                           phoneMiddleNum: String,
                           phoneLastNum: String) -> Observable<CommonValidation> {
        
    return Observable.create { observer in
        if input.isEmpty {
           observer.on(.next(.INVALID(.PIN(birthDay: birthDay,
                                           phoneLastNumber: phoneLastNum,
                                           phoneMiddleNumber: phoneMiddleNum))))
           observer.onCompleted()
        }
        let pins: [Int] = input.map { Int(String($0)) ?? 0}
        let diffs = zip(pins, pins.dropFirst()).map(-)
        var sequenceValidation: Bool = true
        var equalValidation: Bool = true
        var userValidation: Bool = true
        for i in 0..<diffs.count - 1 {
            if diffs[i] == 1 && diffs[i] == diffs[i+1] { sequenceValidation = false }
            if diffs[i] == -1 && diffs[i] == diffs[i+1] { sequenceValidation = false }
            if diffs[i] == 0 && diffs[i] == diffs[i+1] { equalValidation = false }
        }
            
        let birthDaySuffix = birthDay.suffix(4)
        let birthDayPrefix = (birthDay.prefix(6)).suffix(4)
        if input.contains(birthDaySuffix)
            || input.contains(birthDayPrefix)
            || input.contains(phoneLastNum)
            || input.contains(phoneMiddleNum) {
            userValidation = false
        }
            
        let isValid = sequenceValidation && equalValidation && userValidation
        let validation = isValid
            ? CommonValidation.VALID
            : CommonValidation.INVALID(.PIN(birthDay: birthDay,
                                            phoneLastNumber: phoneLastNum,
                                            phoneMiddleNumber: phoneMiddleNum))
        observer.on(.next(validation))
        observer.onCompleted()
            
        return Disposables.create()
    }
}

 

연속된 숫자와 계단식으로 증가되는 숫자를 검출하기 위해,

 

입력받은 각 자리값의 차이값을 구하였습니다.

 

차이값들의 배열 diffs를 순회하며,

 

차이값이 0인 요소가 연속으로 두번 검출 => 같은 숫자 3번 반복하여 입력

 

차이값이 1인 요소가 연속으로 두번 검출 => 계단식으로 감소하는 3자리 이상 숫자 입력

 

차이값이 -1인 요소가 연속으로 두번 검출 => 계단식으로 증가하는 3자리 이상 숫자 입력

 

위 방법으로 1, 2번 케이스를 검출하였습니다.

 

또한 하단 함수를 호출하는 시점은 사용자가 로그인이 되어 있는 시점,

 

또는 가입을 위해 사용자 정보를 받아온 상태이기 때문에 

 

전달받은 휴대폰 번호와 생년월일을 통해 3, 4번 케이스를 손쉽게 검출할 수 있었습니다.

 

3. 입력한 핀코드 암호화

해당 내용의 경우 보안상 공개할 수 없으므로,

 

간략히만 설명드리겠습니다.

 

우선 입력한 핀코드 평문은 유효성이 확인되는 즉시, 

 

단방향 암호화 (SHA256)을 통해 특정 문자열 값으로 변환되며,

 

해당 값을 서버에 요청 시 RSA 암호화 및 전자서명을 적용하여,

 

기밀성과 무결성을 보장합니다.

 

또한 서버로 전송된 값을 데이터 베이스에 저장 시,

 

사용자에 특정된 키로 암호화되어 저장됩니다.

 

 

현재 해당 코드는 회사에서 사용중인 프로젝트에 속해있기에

 

추후에 보안 키패드 기능만 분리한 프로젝트 생성 후 프로젝트 공유하도록 하겠습니다.

 

 

 

https://github.com/rnjstjddn96/SecurePincode

 

GitHub - rnjstjddn96/SecurePincode

Contribute to rnjstjddn96/SecurePincode development by creating an account on GitHub.

github.com

 

해당 코드는 위 깃에서 확인해주시면 되겠습니다.

 

긴 글 읽어주셔서 감사합니다! 

'Swift' 카테고리의 다른 글

생체인증을 통한 본인인증 제작기  (0) 2022.06.07
Initializer  (0) 2022.01.27
Memory에 대한 이해  (0) 2021.02.28
Lazy in Swift  (0) 2021.02.28