업데이트:

프로젝트 소개

어바웃 송담 API 서버

 어바웃 송담 API서버(내부 프로젝트 명 ccsApi)는 어바웃 송담 커뮤니티 회원으로부터 요청받은 API 요구사항을 토대로 API 서버를 구축한 후 배포하여 커뮤니티의 회원정보와 API정보로 생성 된 암호화 KEY를 통해 접근 할 수 있도록 만든 RestAPI 서버이다.

 API 키를 생성하기 위한 별도 서비스, API 이용자를 식별하고 관리하기 위한 서비스, 예제 프로젝트 노란하늘을 위해 만들어진 API 서비스로 구성되어 있다.

어바웃 송담 프로젝트 아키텍처

 기본 아키텍처는 어바웃 송담 서버 동일하게 MVC, DTO에 Spring JPA를 적용하였다. 데이터베이스도 mySQL을 사용하나 커뮤니티와는 별도의 전용 database를 이용해 기능적으로는 종속될 수 있더라도 구조적으로는 커뮤니티 서버에 종속되지 않은 독립된 서버가 되도록 구성하였다.

ccsApi 다이어그램

 개발을 마친 API서버의 최종적인 형태는 위와 같다. API 서버 유저를 관리하는 두 API는 커뮤니티 서버와 상호작용을 하고, 노란하늘 API는 노란하늘이 요구하는 위치정보를 DB로부터 등록/삭제/수정하는 상호작용 로직을 가지고 있다.

API서버의 꽃, 암호화 키

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class CryptoManager {
    private final String IV = "265cfc162cfdf7c3";

    public String encrypt(String plaintext, String key) throws Exception{
        // 랜덤한 salt 생성
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);

        SecretKeySpec secretKeySpec = getKeySpec(key, salt);

        // 암호화 준비
        byte[] iv=IV.getBytes();
        IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);

        // 평문을 암호화
        byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes());

        // 암호화된 데이터와 salt를 Base64로 인코딩하여 반환
        String saltString = Base64.getEncoder().encodeToString(salt);
        String encryptedString = Base64.getEncoder().encodeToString(encryptedBytes);
        return saltString + ":" + encryptedString;
    }

    public String decrypt(String ciphertext, String key) throws Exception {
        // 입력된 암호문을 salt와 암호화된 데이터로 분리
        String[] parts = ciphertext.split(":");
        byte[] salt = Base64.getDecoder().decode(parts[0]);
        byte[] encryptedBytes = Base64.getDecoder().decode(parts[1]);

        // 입력된 키 문자열을 기반으로 SecretKey 생성
        SecretKeySpec secretKeySpec = getKeySpec(key, salt);

        // 복호화 준비
        byte[] iv=IV.getBytes();
        IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec,ivParameterSpec);

        // 암호문을 복호화
        byte[] decryptedBytes = cipher.doFinal(encryptedBytes);

        // 평문으로 복호화된 데이터 반환
        return new String(decryptedBytes);
    }

    private SecretKeySpec getKeySpec(String key, byte[] salt) throws Exception {
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        PBEKeySpec spec = new PBEKeySpec(key.toCharArray(), salt, 65536, 256);
        SecretKey secretKey = factory.generateSecret(spec);
        return new SecretKeySpec(secretKey.getEncoded(), "AES");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// public class UserService 내용 일부
// API 발급
    public String issue(String id, Integer studNum, String apiId) throws Exception {
        // API KEY 구조 : {회원 ID}/{API ID}
        // API KEY 에 대한 복호화 키 : {학번}

        ApiListEntity apiList = apiListRepository.findById(apiId);

        String issuedKey;
        CryptoManager manager = new CryptoManager();

        issuedKey = manager.encrypt(id+"/"+apiId, String.valueOf(studNum));
        issuedKey = URLEncoder.encode(issuedKey, StandardCharsets.UTF_8);
        ApiUserListEntity entity = ApiUserListEntity.builder()
                .apiKey(issuedKey)
                .userStudNum(studNum)
                .listName(apiList.getName())
            .build();

        userListRepository.save(entity);

        return issuedKey;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// public class AuthService 내용 일부
private final CryptoManager cryptoManager = new CryptoManager();

    public String decrypt(String key, Integer studNum) {
        try {
            return cryptoManager.decrypt(key, String.valueOf(studNum));
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public String auth(String decryptedKey, Integer studNum) {
        // API KEY 구조 : {회원 ID}/{API ID}
        // API KEY 에 대한 복호화 키 : {학번}
        String[] strings = decryptedKey.split("/");

        // 학번 으로 CCS 계정 조회
        CcsUserEntity findCcsUser = ccsUserRepository.findByStudNum(studNum);
        ApiUserEntity findApiUser = apiUserRepository.findByStudNum(studNum);
        if(findCcsUser==null || findApiUser==null) // 계정 유효성 검사
            return "ERR_USER_NOT_FOUND";
        else if(!findCcsUser.getId().equals(strings[0])) // KEY 의 ID와 학번 으로 조회 한 계정의 ID 검사
            return "ERR_KEY_NOT_MATCH";

        ApiListEntity apiList = apiListRepository.findById(strings[1]); // API 유효성 검사
        if (apiList==null)
            return "ERR_API_NOT_FOUND";

        return "OK";
    }

 어느 서비스나 서버가 그렇지 않겠냐만은 API서버에서 가장 중요한것은 ‘아무나 쉽게 내부에 접근 가능해선 안 된다’일 것이다. 다수의 엔드 포인트가 존재할 수 있다는 restAPI의 한계에 더해, 엄한 요청이 들어온다면 그 자체로 트래픽의 낭비라고 볼 수 있으며, 내부 정보 접근까지 가능하다면 중대한 보안 이슈이기 때문이다.

 다만 오늘날 사이버 보안의 동의어는 사용의 불편함이기 때문에, 아무리 안전과 보안에 필요 이상의 지나친것이란 없다고 하더라도, 과도한 보안은 개발 자원 증가와 사용성을 헤친다. 때문에 실제 해킹에 대응해야 할 필요성이 낮았던 어바웃 송담 프로젝트에서는 최소한의 보안 수준을 구축하는것으로 타협하여야 하였다.

 오픈API 서비스들도 최소한 회원제/신청제로 API키를 발급해 무분별한 요청을 방지하는 등으로 보안 및 관리 시스템을 가지고 있다. 따라서 어바웃송담 프로젝트도 데드라인이 촉박하여 보안보다는 실제 기능 구현 위주로 개발하게 되었으나, API 엔드포인트는 필연적으로 드러나더라도 이러한 인증체계마저 구현하지 않을 수는 없었다.

 위에 인용한 코드 블럭에는 없는 내용이나 부연하자면 데이터베이스와 클라이언트(세션)에서 받아온 학번을 대조하여 일치를 확인한 후, 코드 블럭의 내용과 같이 랜덤salt + 학번 -> PBKDF2(유도키 생성) -> AES 알고리즘 암호화구조를 가진 비밀번호 기반 암호화(PBE: Password-Based Encryption)의 전형적인 패턴을 사용해 AES-256-CBC 알고리즘으로 암호화 후 Base64 인코딩된 평문을 API 요청 키로 사용하도록 최소한의 구색은 갖추어 기초적인 인증 체계를 만들었다.

 하지만 글을 작성하며 AuthService 객체의 코드를 천천히 다시 살펴보다 보니 auth 메서드에 로직 오류를 발견하였다. if(findCcsUser==null || findApiUser==null)구문에서 OR 로직이 아닌 AND 로직을 사용하고 API계정이 등록되어 있는지 따로 검사하는 else if 파트가 필요하나 이것이 생략되어 오류가 발생할 수 있을 여지가 있다. 클라이언트 구조 상 반드시 API계정이 만드는것이 선행된 후에 동작하는 코드이기는 하나 ‘인증’을 담당함에도 로직에 대한 완전한 무결성을 보장하지 못하는 실수이다.

해당 파트를 되짚어보며