14주차 ‐ 토양 센서값 전송(카카오톡 활용) - movie-01/SmartDevice GitHub Wiki
- OAuth 2.0 인증 체계 정리
- 토양 습도 센서
- 실습
OAuth 2.0의 Authorization Code Grant는 보안성이 높은 인증 방식으로, 주로 웹 애플리케이션에서 사용된다. 사용자의 권한을 서버가 직접 획득하지 않고, **인가 코드(authorization code)**를 발급받아 **액세스 토큰(access token)**으로 교환하는 구조이다.
클라이언트 앱 → 리소스 소유자(사용자) → 인가 서버 → 리소스 서버 순으로 흐름이 전개된다.
- 인가 코드 요청
- 클라이언트가 인가 서버에 /authorize 엔드포인트로 요청
GET https://kauth.kakao.com/oauth/authorize?
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
response_type=code&
scope=talk_message
-
리디렉션 수신
- 사용자가 권한을 승인하면, 브라우저가 redirect_uri로 리디렉션되며, URL 파라미터에 ?code=AUTH_CODE를 포함.
-
토큰 교환
- 클라이언트가 /token 엔드포인트로 POST 요청을 보내 인가 코드를 액세스 토큰으로 교환
POST https://kauth.kakao.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
code=AUTH_CODE
파라미터 | 역할 설명 |
---|---|
client_id | 애플리케이션의 식별자 |
redirect_uri | 인가 코드 수신을 위한 리디렉션 주소 |
grant_type | 인증 방식 (authorization_code 등) |
scope | 요청할 사용자 권한 범위 (예: profile, talk_message) |
오류 코드 | 설명 |
---|---|
invalid_grant | 유효하지 않거나 만료된 인가 코드 사용 |
invalid_scope | 지원되지 않거나 잘못된 scope 요청 |
redirect_uri mismatch | 등록된 redirect_uri와 다를 경우 |
insufficient_scopes | 리소스 접근 권한 부족 |
- talk_message 등 민감한 권한은 scope 파라미터로 명시적으로 요청해야 하며, 카카오 디벨로퍼스 콘솔에서도 별도로 동의 항목 설정 필요.
- HTTPS 필수 사용: 인가 코드 및 토큰 노출 방지
- 토큰 보안: 액세스 토큰을 안전하게 저장하고, Refresh Token으로 갱신 처리 필요
- CSRF 보호: state 파라미터로 공격 방지
센서는 토양 내 수분 함량에 따라 전기 저항이 달라지는 원리를 이용한다. 수분이 많을수록 전류가 잘 흐르고, 저항이 작아진다.
- VCC — 센서 — A0(아날로그 입력) — GND
- ESP32, Arduino 등에 연결 가능
int sensorValue = analogRead(A0); // 0~4095 범위 (ESP32)
Serial.println(sensorValue);
상태 | 센서 출력값 예시 |
---|---|
완전 건조 | 약 0~300 |
물에 담금 | 약 3000~4095 |
- map() 함수를 사용해 비율 변환 가능
int moisturePercent = map(sensorValue, 0, 4095, 0, 100);
- 🌿 자동 급수 시스템: 일정 수치 이하로 떨어지면 펌프 작동
- 📡 IoT 연동 원격 모니터링: 측정값을 앱이나 API로 전송해 실시간 확인
- ESP32
- ESP32 확장 실드
- 토양 습도 센서
- ArduinoJson by Benoit Blanchon 라이브러리 설치
#include <ArduinoJson.h>
void setup() {
// Initialize serial port
Serial.begin(115200);
while (!Serial) continue;
delay(3000);
// JSON 문서 할당
//
// 괄호 안의 200은 메모리 풀의 용량(바이트)입니다.
// JSON 문서와 일치하도록 이 값을 변경하는 것을 잊지 마십시오.
// https://arduinojson.org/v6/assistant 를 사용하여 용량을 계산합니다.
StaticJsonDocument<96> doc;
// StaticJsonDocument<N>은 스택에 메모리를 할당합니다.
// 힙에 할당하는 DynamicJsonDocument로 대체할 수 있습니다.
// DynamicJsonDocument doc(200);
// JSON 입력 문자열.
char json[] = R"rawliteral({
"token_type":"bearer",
"access_token":"c281d73b097",
"expires_in":43199,
"refresh_token":"0a0c90af08f",
"refresh_token_expires_in":5184000,
"scope":"account_email profile"
})rawliteral";
Serial.println(json);
// Deserialize the JSON document
DeserializationError error = deserializeJson(doc, json);
// Test if parsing succeeds.
if (error) {
Serial.print(F("deserializeJson() failed: "));
Serial.println(error.f_str());
return;
}
// Fetch values.
const char* access_token = doc["access_token"];
const char* refresh_token = doc["refresh_token"];
long expires_in = doc["expires_in"];
// Print values.
Serial.print("Access token : ");
Serial.println(access_token);
Serial.print("Refresh token : ");
Serial.println(refresh_token);
Serial.print("Expire time : ");
Serial.println(expires_in);
}
void loop() {
// not used in this example
}
-
카카오톡 개발자 사이트 회원 가입 및 로그인
-
앱 등록 및 정보 설정
- 애플리케이션 추가
- 좌측 메뉴의 "앱 키"에서 REST API키 복사
- web플랫폼 사이트 도메인 https://www.example.com 등록
- 앱 키 - REST API 키 복사
- 플랫폼 - 사이트 도메인 -> "http://localhost/" 입력
- 카카오 로그인 - 활성화 설정 ON, Redirect URL: "https://example.com/oauth" 입력
- 동의항목 - 카카오톡 메시지 전송(선택동의 설정)
- Access 토근 발급
- 지급 받은 [REST API키] 수정하기(대괄호 지우고 수정하기)
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=[REST API키]&redirect_uri=https://www.example.com/oauth
- 동의 후, URL에 code= 이후의 값 복사
- Access 토큰 발급 받기
- 위에서 얻은 REST_API 키 값과 Redirection_URL, Authorization Code 값을 이용
- Windows의 cmd창 명령 프롬프트에 다음과 같이 조합하여 입력
curl -v -X POST "https://kauth.kakao.com/oauth/token" -H "Content-Type: application/x-wwwform-urlencoded" -d "grant_type=authorization_code" -d "client_id=${REST_API_KEY}" --dataurlencode "redirect_uri=${REDIRECT_URI}" -d "code=${AUTHORIZE_CODE}"
#include <WiFi.h>
#include <HTTPClient.h>
const char *ssid = "Your_SSID"; // 사용하는 WiFi 네트워크 이름 (SSID)
const char *password = "Your_Password"; // 사용하는 WiFi 네트워크 비밀번호
const String rest_api_key = "REST API KEY";
String access_token = "Access token";
String refresh_token = "Refresh token";
#define MsgSendInterval 3600 // 60 * 60 초, 즉 한시간 간격으로 전송
long timeout = 3600; //시간을 초로 나타냄
int sensorValue = 0;
int sensorPin = 34; // 토양 습도 센서 핀
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
Serial.println("Connecting");
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(1000);
}
Serial.print("\nConnected to WiFi : ");
Serial.println(WiFi.localIP());
}
void loop() {
if (timeout++ > MsgSendInterval) // 1시간(60 * 60)에 1번씩 전송
{
if (isAccessTokenExpired() == true) { //access token 만료 여부 확인
if (update_access_token() == false) { // access token 재발급
Serial.println("Access token update failed");
}
}
sensorValue = analogRead(sensorPin);//토양 센서값 읽기
send_message();
timeout = 0;
}
delay(1000);
}
// str문자열에서 start_string와 end_string사이의 문자열을 추출하는 함수
String extract_string(String str, String start_string, String end_string) {
int index1 = str.indexOf(start_string) + start_string.length();
int index2 = str.indexOf(end_string, index1);
String value = str.substring(index1, index2);
return value;
}
bool isAccessTokenExpired() {
HTTPClient http;
bool returnVal = true;
/*
curl -v -X GET "https://kapi.kakao.com/v1/user/access_token_info" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
*/
if (!http.begin("https://kapi.kakao.com/v1/user/access_token_info")) {
Serial.println("\nfailed to begin http\n");
}
http.addHeader("Authorization", "Bearer " + access_token);
int httpCode = http.GET();
// httpCode will be negative on error
if (httpCode > 0) {
// file found at server
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
Serial.println(payload);
String expireT = extract_string(payload, "\"expires_in\":", ",");
Serial.println(expireT.toInt());
if (expireT.toInt() > 0) {
returnVal = false;
} else {
returnVal = true;
}
}
} else {
Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
return returnVal;
}
void send_message() {
HTTPClient http;
String url = "https://kapi.kakao.com/v2/api/talk/memo/default/send";
if (!http.begin(url)) {
Serial.println("\nfailed to begin http\n");
}
http.addHeader("Authorization", "Bearer " + access_token);
http.addHeader("Content-Type", "application/x-www-form-urlencoded");
int http_code;
/*
template_object={
"object_type": "text",
"text": "텍스트 영역입니다. 최대 200자 표시 가능합니다.",
"link": {
"web_url": "https://developers.kakao.com",
"mobile_web_url": "https://developers.kakao.com"
},
"button_title": "바로 확인"
}
*/
String data = String("template_object={") +
String("\"object_type\": \"text\",") +
String("\"text\": \"") + String("토양 센서 값 :") +
String(sensorValue) + //토양 센서 값
String("\",\"link\": {}}"); //link가 없으면 오류메세지 받음
Serial.println(data);
http_code = http.POST(data);
Serial.print("HTTP Response code: ");
Serial.println(http_code);
String response;
if (http_code > 0) {
response = http.getString();
Serial.println(response);
}
http.end();
}
/*
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=${REST_API_KEY}" \
-d "refresh_token=${USER_REFRESH_TOKEN}"
*/
bool update_access_token() {
HTTPClient http;
bool retVal = false;
String url = "https://kauth.kakao.com/oauth/token";
String new_refresh_token = "";
if (!http.begin(url)) {
Serial.println("\nfailed to begin http\n");
}
http.addHeader("Content-Type", "application/x-www-form-urlencoded");
int http_code;
String data = "grant_type=refresh_token&client_id=" + rest_api_key + "&refresh_token=" + refresh_token;
Serial.println(data);
http_code = http.POST(data);
Serial.print("HTTP Response code: ");
Serial.println(http_code);
String response;
if (http_code > 0) {
response = http.getString();
Serial.println(response);
access_token = extract_string(response, "{\"access_token\":\"", "\"");
new_refresh_token = extract_string(response, "\"refresh_token\":\"", "\"");
//만료 1개월전부터 갱신되므로 data가 없을 수도 있음
if (new_refresh_token != "") {
refresh_token = new_refresh_token;
}
retVal = true;
} else {
retVal = false;
}
http.end();
return retVal;
}