TIL | WIL

3/18 토 (localhost에 HTTPS 연결하기와 Geolocation 웹 API 실험) TIL, TIT

깊은바다거북 2023. 4. 7. 21:56

(최종 프로젝트 진행중)

할 일

  • Date input타입을 서버로 보내기
  • 시작 날짜와 끝 날짜를 requests 테이블에 저장하기
  • ‘모집중|모집완료’ 컬럼을 requests 테이블에 추가하기
  • ‘작성 완료’ 버튼 ajax 연결하기
  • 수정하기 페이지에 기본 글과 함께 불러와놓기
  • 작성자 본인이면 수정하기 및 삭제 버튼이 나타나도록 하기
  • ‘수정 완료’ 버튼 ajax 연결하기
  • ‘모집중’→’모집완료’로 바꿀 방법 생각하기
  • 삭제는 마이페이지에 가서 하도록 할 생각. ?

  • user에게서 받은 집주소로 geoCoder.address…()으로 좌표와 동네명 얻기.
  • ‘처음에 테이블 구조를 단순히 address만 있는 항목으로 만들어서 아직 수정하지 못했다”


※ 이하는 스스로 공부하며 적어둔 노트이며 불확실한 내용이 있을 수 있습니다. 학습용으로 적합하지 않음을 유념해주세요. ※

localhost에 HTTPS 연결하기

  1. mkcert를 전역으로 설치
    npm install -g mkcert
  1. CA(Certificate Authority) 만들기 = CA private key와 certificate 만들기
    mkcert create-ca

    ⇒ 만들어진 CA private key(=ca.key)는 안전한 곳에 보관하도록 한다.

  1. localhost 도메인의 인증서 만들기 = 2에서 만든 ca.key와 ca.crt를 가지고 cert.key과 cert.crt 만들기
    mkcert create-cert
  1. 3에서 만들어진 cert-crt와 cert-key을 config 폴더에 옮기고
  1. ca.crt와 ca.key, cert.crt와 cert.key는 .gitignore에 등록해줘야 한다.

  • (참고한 예시 1.) 6. 코드 수정 (이를 참고하여 하단의 ‘결과’에 내 서버에서 작동하도록 코드를 써 놓았으니 그걸 참고하면 좋다)
    const express = require("express");
    const expressSanitizer = require("express-sanitizer");
    
    // fs and https 모듈 가져오기
    const https = require("https");
    const fs = require("fs");
    
    // certificate와 private key 가져오기
    // ------------------- STEP 2
    const options = {
      key: fs.readFileSync("./config/cert.key"),
      cert: fs.readFileSync("./config/cert.crt"),
    };
    
    const app = express();
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    app.use(expressSanitizer());
    app.use("/", express.static("public"));
    
    const PORT = 8000;
    
    // http 서버는 8000번 포트로 실행
    app.listen(PORT, () => {
      console.log(`Server started on port ${PORT}`);
    });
    
    // https 의존성으로 certificate와 private key로 새로운 서버를 시작
    https.createServer(options, app).listen(8080, () => {
      console.log(`HTTPS server started on port 8080`);
    });

    7. http://localhost:8000과 https://localhost:8080으로 두 개의 서버가 실행되는 것을 확인하기

    (참고: https://charming-kyu.tistory.com/46)

  • 공식 Nest.js 문서가 소개하는 HTTPS 싱글 서버 생성하기:
const httpsOptions = {
  key: fs.readFileSync('./secrets/private-key.pem'),
  cert: fs.readFileSync('./secrets/public-certificate.pem'),
};
const app = await NestFactory.create(AppModule, {
  httpsOptions,
});
await app.listen(3000);

  • 공식 Nest.js 문서가 소개하는 HTTPS와 HTTP 멀티 서버 생성하기:
const httpsOptions = {
  key: fs.readFileSync('./secrets/private-key.pem'),
  cert: fs.readFileSync('./secrets/public-certificate.pem'),
};

const server = express();
const app = await NestFactory.create(
  AppModule,
  new ExpressAdapter(server),
);
await app.init();

http.createServer(server).listen(3000);
https.createServer(httpsOptions, server).listen(443);

⇒ private-key.pem, public-certificate.pem이라는 게 대신 있네 여긴.

  • HTTPS 가 좋은 점:

Secure cookie를 사용할 수 있음(Keycloack, Auth0 같은 Auth서비스를 이용할 때)

프로덕션 환경에서는 https일 떄가 많을 테니 개발 환경에서도 최대한 비슷하게 맞추려고

어떤 SaaS 서비스들은 https를 요구하므로 (Geolocation Web api도 비슷한 케이스)

  • private-key.pem, public-certificate.pem 발급받기
    1. (개발 환경에서 https로열) hostname을 정한다.

      무슨 소린지 모르겠음…

    1. mkcert을 설치하고 나서
    1. 이런 명령어로 만든다는 걸로 봐서…
      $ mkcert -cert-file certs/local-cert.pem -key-file certs/local-key.pem dev.local *.dev.local

    위의 Nest.js 공식문서 예제에서도 mkcert으로 공개키와 인증서를 만든는 건 똑같고 그냥 파일 이름을 private-key.pem, public-certificate.pem 라고 내가 정할 수 있는 것 같다.

    1. 그리고 참고할 수 있는 main.ts 코드는…
      // main.ts
      import { Logger } from '@nestjs/common';
      import { NestFactory } from '@nestjs/core';
      import * as fs from 'fs';
      import * as path from 'path';
      
      import { AppModule } from './app/app.module';
      
      async function bootstrap() {
        const ssl = process.env.SSL === 'true' ? true : false;
        let httpsOptions = null;
        if (ssl) {
          const keyPath = process.env.SSL_KEY_PATH || '';
          const certPath = process.env.SSL_CERT_PATH || '';
          httpsOptions = {
            key: fs.readFileSync(path.join(__dirname, keyPath)),
            cert: fs.readFileSync(path.join(__dirname, certPath)),
          };
        }
        const app = await NestFactory.create(AppModule, { httpsOptions });
        const port = Number(process.env.PORT) || 3333;
        const hostname = process.env.HOSTNAME || 'localhost';
        await app.listen(port, hostname, () => {
          const address =
            'http' + (ssl ? 's' : '') + '://' + hostname + ':' + port + '/';
          Logger.log('Listening at ' + address);
        });
      }
      
      bootstrap();

      결론:

      ⇒ mkcert에서 만들어진 파일 이름은 중요하지 않다. key와 cert를 구분하여 제대로 경로를 넣어주기만 하면 된다.

      ⇒ hostname까지 내가 정하고 싶을 땐 저렇게 app.listen할 때 지정해주면 되겠다.

      ⇒ 이 방식으로 하면 https를 켰다 껐다를 .env에서 SSL=true/false로만 조절할 수 있겠다.

      // .env 예시
      PORT=3334
      HOSTNAME=dev.local
      SSL=true
      SSL_KEY_PATH="../../../dev-stack/certs/local-key.pem"
      SSL_CERT_PATH="../../../dev-stack/certs/local-cert.pem"

    (참고: https://dev.to/nightbr/full-https-ssl-development-environment-4dam)

    (참고: https://dev.to/nightbr/local-https-for-nestjs-app-api-in-nx-workspace-54n2)

결과

위의 예시들을 참고하고 짬뽕하여 동작하게 만든… 일단 https 싱글 서버 코드:

// src/main.ts
...
import * as fs from 'fs';

async function bootstrap() {
  const httpsOptions = {
    key: fs.readFileSync('src/config/cert.key'),
    cert: fs.readFileSync('src/config/cert.crt'),
  };
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    httpsOptions,
  });
  
  await app.listen(3000, () => {
    console.log('3000번 포트로 서버가 열렸습니다. https://localhost:3000');
  });
}
bootstrap();

⇒ 위와 같이 했더니 https://localhost:3000로 서버가 열렸다..!

팁: fs.readFileSync()로 읽을 때 경로명을 ‘./config/cert.key’나 ‘../config/cert.key’로 하면 이상하게 파일을 못 찾는다. 저렇게 ‘src/config/cert.key’라고 해주니 앱이 실행됐다.

  • 첫 접속 시 발생한 사소한 에러

    엥 처음에 저 주소로 브라우저 창에 입력하니 이런 에러가 떴는데

    3000번 포트로 서버가 열렸습니다. https://localhost:3000
    [Nest] 20316  - 2023. 03. 18. 오후 4:01:21   ERROR [ExceptionsHandler] jwt expired
    TokenExpiredError: jwt expired
        at C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\jsonwebtoken\verify.js:190:21
        at getSecret (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\jsonwebtoken\verify.js:97:14)
        at Object.module.exports [as verify] (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\jsonwebtoken\verify.js:101:10)
        at JwtService.verify (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\@nestjs\jwt\dist\jwt.service.js:38:20)
        at AuthMiddleware.use (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\src\auth\auth.middleware.ts:34:41)
        at C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\@nestjs\core\router\router-proxy.js:9:23
        at middlewareFunction (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\@nestjs\core\middleware\middleware-module.js:166:28)
        at Layer.handle [as handle_request] (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\layer.js:95:5)
        at next (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\route.js:144:13)
        at Route.dispatch (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\route.js:114:3) 
    :19)
        at C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\@nestjs\core\router\router-proxy.js:9:23
        at Layer.handle [as handle_request] (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\layer.js:95:5)
        at trim_prefix (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\index.js:328:13)   
        at C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\index.js:286:9    at Function.process_params (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\index.js:346:12)
        at next (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\index.js:280:10)    at urlencodedParser (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\body-parser\lib\types\urlencoded.js:91:7)
        at Layer.handle [as handle_request] (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\layer.js:95:5)    at trim_prefix (C:\Users\USER\Desktop\Sparta\05_project_무인냥품\node_modules\express\lib\router\index.js:328:13)   
    <===========================>

    새로고침하니 페이지가 잘 보인다.

    처음에 kaspersky가 유효하지 않은 인증서라고 막았던 것이 원인이 되었나..?

중요:

팀원들이 위의 1~5번 과정을 각각 실행해야만 함.

// 안내용 커밋 메세지: 
[추가] https 인증서와 개인키 발급을 위한 mkcert 라이브러리 설치
사용법(처음 한번은 꼭 해주셔야 합니다!):
    - 1. npm install -g mkcert (mkcert를 전역으로 설치)
    - 2. mkcert create-ca (CA 인증서와 개인키인 ca.crt, ca.key 파일 만들기)
    - 3. mkcert create-cert (2에서 만들어진 파일들을 기반으로 cert.key, cert.crt 파일이 만들어짐)
    - 4. 2에서 만든 두 개 파일은 따로 안전한 곳에 보관합니다. (저희 앱에서 쓰이지 않지만 비밀키이므로 보관합니다)   
    - 5. 3에서 만든 두 개 파일을 src/config/ 폴더 안에 옮겨놓습니다.
    - 6. 위의 4개 파일은 .gitignore에 이미 등록시켜놨기 때문에 github에 올라가지 않을 거예요 (안전합니다!)
    - 7. 이제 서버를 실행시키면 https://localhost:3000 주소로 접속할 수 있습니다!

걱정: 로그인할 때 https인데도 페이로드에 이메일과 비번이 그대로 보인다.

  • 내가 로그인 페이지 만들 때 뭘 잘못 만든걸가? 프론트 차원에서 암호화를 해서 보냈어야 하는 건가?!

    ⇒ https는 ‘종단간’ 암호화로서, 각 종단(서버와 브라우저)가 무결하다는 전제하에 이루어지는 것이라고 한다. 애초에 한 종단의 보안이 뚫렸으면 https로 종단간 암호화를 하거나 어떤 보호 조치를 해도 소용없다고.

    ⇒ 그래도 클라이언트쪽에서 미리 해시값 등으로 변환을 해서 보내는 게 더 낫다는 의견이 있다. 서버에서 비밀번호 평문값을 알 필요는 없으므로. 결국 ‘프론트 차원에서 암호화를 해서 보냈어야’ 한다는 내 추측이 맞다.

    • ⇒ 근데 프론트에서 bcrypt 암호화 했는데…?

(비슷한 질문과 댓글의 의견들을 참고할 수 있다: https://www.clien.net/service/board/kin/13665880)

[Geolocation Web API] 와 Kakao Map 연계하여 도입하기까지 (느린 사고의 흐름)

  • Geolocation 으로 유저 위치정보 가져오기

    ⇒ Geolocation 으로 가져온 좌표와 다음 우편 api 도로명 주소로 가져오는 좌표는 소수점 2번째 자리까지는 일치하는 것 같다.

  • 유저 정보에 기록된 ‘우편 주소’로 좌표 얻기 ⇒ 불가능
  • 유저 정보에 기록된 ‘동네명’으로 좌표 얻기

    ⇒ 동네명으로 검색할 땐 검색 결과의 무조건 첫 번째 주소에서 좌표를 따오는 방식이라, 실제 유저의 집 주소에서 따온 동네 좌표와 어긋남이 클 수 있다. 아래에서 구체적인 주소로 ‘삼평동’을 얻어내 반경 1km를 표시한 것과, ‘삼평동’으로 검색해서 얻은 첫 번째 위치를 마크로 표시한 것을 보면 상당히 다름을 알 수 있다.

    ⇒ 반경 2km 정도까지를 오차범위로 넘어가준다면 동네명으로 식별을 고려할 만 하겠다.

  • 좌표와 좌표 비교하기 대신, 유저의 GPS 좌표로 동네명 얻기: geocoder.coord2Address(경도, 위도, callback) ⇒ data.address.region_3depth_name 이 바로 bname과 같은가?
  • 유저의 GPS 좌표로 우편 번호 얻기: geocoder.coord2Address(경도, 위도, callback) ⇒ data.road_address.zone_no // ‘04524’

    ⇒ 유저가 비교적 좁은 우편번호 내에 위치해야 한다는 점이 단점으로 작용할지 모르겠다.

    ⇒ 확인 결과 동네명보다 우편번호 구역 크기가 더 작다. (동네명이같아도 우편번호는 다를 수 있음)

  • 우편번호가 일치함으로 동네 인증을 허용할 것인지, 동네명이 일치함으로 허용할 것인지.
  • 그도 아니면 아슬아슬한 동네 경계에 사는, 그리고 위치한 사람을 위해 좌표 기준 반경으로

⇒ 회원가입시 입력한 주소의 좌표와 현재 접속하여 인증하려는 위치가 +-1km 내라면 위치 인증이 되는 것으로 하기로 한다.

1km 반경 내에 위치하는지 여부 구하기:

  1. 기존 ‘주소’ 위치를 마커로 찍는다. (혹은 new daum.maps.LatLng(result.y, result.x)로 얻은 coords 객체를 저장한다)
  1. 현재 접속하여 인증하려는 위치를 마커로 찍는다.(역시 coords 객체를 저장한다)
  1. 둘 사이를 연결하는 Polyline 객체(선)를 그린다
  1. .getLength()로 선의 길이를 구해서 1000 이내면 1km이므로 1을 반환하도록 한다.

예를 들면 집 주소는 이렇게 원의 중심이었는데 동네 이름 ‘삼평동’으로 검색한 첫 주소는 반경 1km를 넘어가므로, 동네 인증 처리를 하지 않도록 한다:

왜 1km로 잡았냐 하면 일단 도보로 15정도 거리라서. 그리고 우편번호 구역의 크기와 3계 동네명으로 나오는 크기가 이정도면 충분히 커버한다고 보아서. 마지막으로, 잘 안되면 나중에 로직을 업데이트하면 된다는 생각에.

// 지도는 그리지 않고 계산값으로 1(1km 이내)과 0(1km 반경내 아님)을 도출하는 코드:
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=내 카카오 JavaScript API 키&libraries=services"  type="text/javascript"></script>

//주소-좌표 변환 객체를 생성
var geocoder = new daum.maps.services.Geocoder();

function isWithin1000m(userAddress) {
	// 유저의 도로명 주소를 불러온다
	// const userAddress = ...
	// 유저의 현재 GPS 위치를 불러온다.
	const userCurrentLocation = getCurrentLocation();

	geocoder.addressSearch(userAddress, function(results, status) {
		if (status === daum.maps.services.Status.OK) {
			let result = results[0];

			// 주소로 검색해 kakao map 좌표 객체를 얻고
			var userAddressCoords = new daum.maps.LatLng(result.y, result.x);
			// 현재 좌표로 kakao map 좌표 객체를 얻어서
			var userCurrentCoords = new daum.maps.LatLng(userCurrentLocation.y, userCurrentLocation.x);

			// 두 좌표를 경로로 하는 폴리라인 설정
			var line = new kakao.maps.Polyline();
			var path = [ userAddressCoords, usercurrentCoords ];
			line.setPath(path);

			console.log('이전 마커와 현재 마커 사이의 거리: ', line.getLength())
			
			const result = line.getLength() <= 1 ? true : false;
			alert(`위치 인증을 마쳤습니다. 결과는 ${result}입니다`)
			
		}
	})
}

function getCurrentLocation() {

		function success(position) {
			const latitude  = position.coords.latitude;
			const longitude = position.coords.longitude;
			return { x: longitude, y: latitude };
		}

		function error() {
			alert('위치 정보를 불러올 수 없습니다.');
		}

		if(!navigator.geolocation) {
			alert('이 브라우저에서는 위치 정보 서비스를 지원하지 않습니다.');
		} else {
			alert('위치 정보를 성공적으로 불러옵니다')
			navigator.geolocation.getCurrentPosition(success, error);
		}
	}

정확한 도로명 주소를 받지 않고 동네명만 받는다면, 동네명으로 검색해서 나오는 상위 10개 정도의 좌표마다 거리를 비교해서 그 중 하나라도 1km 내이면 인증을 인정하는 것으로.

→ 10개 미만이면 미만대로 비교하도록

→ 상위 10개가 너무 한 구석에 몰려있다면, 랜덤 10개로 로직 변경하기.

—> results.length ≤ 10 { 전체에 대한 forEach 거리비교 } else { 전체 중 10개 랜덤 뽑아 forEach 거리비교 }


Uploaded by N2T