guardia-messenger/components/PinLock.tsx

201 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* #30 PIN 코드 잠금 화면
*
* - 4자리 PIN 숫자 패드
* - PIN 저장: sha256(pin) → SecureStore 'grd_pin_hash' (평문 저장 금지)
* - 검증: sha256(입력) === 저장 해시
* - 5회 실패 시 onFail() 호출 (세션 종료)
*
* 사용:
* <PinLock mode="set" onSuccess={...} /> // PIN 신규 설정 (2회 확인)
* <PinLock mode="verify" onSuccess={...} onFail={...} /> // 잠금 해제
*/
import { useState } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { sha256 } from '../services/sha256'
import { COLORS } from '../constants/Config'
const PIN_HASH_KEY = 'grd_pin_hash'
const PIN_LENGTH = 4
const MAX_ATTEMPTS = 5
export const PIN_ENABLED_KEY = 'grd_pin_enabled'
export async function isPinSet(): Promise<boolean> {
const h = await SecureStore.getItemAsync(PIN_HASH_KEY)
return !!h
}
export async function savePin(pin: string): Promise<void> {
// 평문 저장 절대 금지 — SHA-256 해시만 저장
const hash = sha256(pin)
await SecureStore.setItemAsync(PIN_HASH_KEY, hash)
await SecureStore.setItemAsync(PIN_ENABLED_KEY, '1')
}
export async function verifyPin(pin: string): Promise<boolean> {
const stored = await SecureStore.getItemAsync(PIN_HASH_KEY)
if (!stored) return false
return sha256(pin) === stored
}
export async function clearPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_HASH_KEY)
await SecureStore.deleteItemAsync(PIN_ENABLED_KEY)
}
export async function isPinEnabled(): Promise<boolean> {
const v = await SecureStore.getItemAsync(PIN_ENABLED_KEY)
return v === '1' && (await isPinSet())
}
type Mode = 'set' | 'verify'
interface Props {
mode: Mode
onSuccess: () => void
onFail?: () => void
onCancel?: () => void
}
export default function PinLock({ mode, onSuccess, onFail, onCancel }: Props) {
const [pin, setPin] = useState('')
const [firstPin, setFirstPin] = useState<string | null>(null) // set 모드 확인용
const [attempts, setAttempts] = useState(0)
const [error, setError] = useState('')
const title =
mode === 'set'
? firstPin == null
? 'PIN 설정'
: 'PIN 재입력'
: 'PIN 입력'
const subtitle =
mode === 'set'
? firstPin == null
? '4자리 PIN을 설정하세요'
: '확인을 위해 다시 입력하세요'
: '잠금을 해제하려면 PIN을 입력하세요'
const handleDigit = async (d: string) => {
if (pin.length >= PIN_LENGTH) return
setError('')
const next = pin + d
setPin(next)
if (next.length === PIN_LENGTH) {
await submit(next)
}
}
const handleDelete = () => {
setError('')
setPin((p) => p.slice(0, -1))
}
const submit = async (entered: string) => {
if (mode === 'set') {
if (firstPin == null) {
setFirstPin(entered)
setPin('')
return
}
if (firstPin === entered) {
await savePin(entered)
onSuccess()
} else {
setError('PIN이 일치하지 않습니다. 다시 설정하세요.')
setFirstPin(null)
setPin('')
}
return
}
// verify
const ok = await verifyPin(entered)
if (ok) {
setAttempts(0)
onSuccess()
} else {
const a = attempts + 1
setAttempts(a)
setPin('')
if (a >= MAX_ATTEMPTS) {
setError('5회 실패 — 보안을 위해 로그아웃됩니다.')
onFail?.()
} else {
setError(`PIN이 올바르지 않습니다. (${a}/${MAX_ATTEMPTS})`)
}
}
}
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'del']
return (
<View style={s.container}>
<Text style={s.logo}>🛡</Text>
<Text style={s.title}>{title}</Text>
<Text style={s.subtitle}>{subtitle}</Text>
<View style={s.dots}>
{Array.from({ length: PIN_LENGTH }).map((_, i) => (
<View key={i} style={[s.dot, i < pin.length && s.dotFilled]} />
))}
</View>
{!!error && <Text style={s.error}>{error}</Text>}
<View style={s.pad}>
{keys.map((k, i) =>
k === '' ? (
<View key={i} style={s.key} />
) : k === 'del' ? (
<TouchableOpacity key={i} style={s.key} onPress={handleDelete}>
<Text style={s.keyDel}></Text>
</TouchableOpacity>
) : (
<TouchableOpacity key={i} style={s.key} onPress={() => handleDigit(k)}>
<Text style={s.keyText}>{k}</Text>
</TouchableOpacity>
)
)}
</View>
{onCancel && (
<TouchableOpacity onPress={onCancel} style={s.cancel}>
<Text style={s.cancelText}></Text>
</TouchableOpacity>
)}
</View>
)
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.gnbBg,
alignItems: 'center',
justifyContent: 'center',
padding: 28,
},
logo: { fontSize: 44, marginBottom: 12 },
title: { fontSize: 22, fontWeight: '800', color: '#fff', marginBottom: 6 },
subtitle: { fontSize: 13, color: 'rgba(255,255,255,.6)', marginBottom: 28 },
dots: { flexDirection: 'row', gap: 18, marginBottom: 18 },
dot: {
width: 16, height: 16, borderRadius: 8,
borderWidth: 2, borderColor: COLORS.accent, backgroundColor: 'transparent',
},
dotFilled: { backgroundColor: COLORS.accent },
error: { color: '#fca5a5', fontSize: 13, marginBottom: 12, textAlign: 'center' },
pad: { flexDirection: 'row', flexWrap: 'wrap', width: 264, justifyContent: 'space-between' },
key: {
width: 78, height: 78, borderRadius: 39,
alignItems: 'center', justifyContent: 'center', marginVertical: 6,
},
keyText: { fontSize: 28, color: '#fff', fontWeight: '600' },
keyDel: { fontSize: 26, color: 'rgba(255,255,255,.7)' },
cancel: { marginTop: 24 },
cancelText: { color: COLORS.accent, fontSize: 15, fontWeight: '600' },
})