Wemos authenticate qua laravel (phần 4.4 - Server kết nối với Wemos)

Giới thiệu

Xin chào các bạn! Bài trước mình đã giới thiệu với các bạn cách tạo gửi request GET và POST trong wemos. Hôm nay chúng ta sẽ cùng xây dựng chức năng đăng nhập vào server laravel và kết nối socket.io sử dụng jwt để xác thực cho wemos nhé.

Xem lại sơ đồ cho đỡ quên nào:

Mình sẽ nói lại các bước hoạt động để các bạn dễ hình dung nhé:

  • Gửi request đăng nhập vào server laravel và nhận được jwt-token
  • Kết nối với socket.io
  • On event thay đổi trạng thái của đèn. Khi có event thì thay đổi trạng thái tương ứng.
  • Emit trạng thái hiện tại của đèn

Ở phần Kết nối với socket.io do có bước xác thực người dùng nên luồng kết nối sẽ như sau:

  • Thiết lập bắt tay
  • Thực hiện authenticate sử dụng jwt-token đã nhận được
  • Chuyển đổi giao thức từ HTTP thành Web socket

Viết chương trình server

Chúng ta sẽ sử dụng chương trình server đã tạo từ bài trước.

Các bạn hãy cài đặt như project laravel bình thường sau đó chạy lệnh php artisan migrate --seed để tạo dữ liệu test nhé.

Sửa lại device models

Muốn thư viện tymon/jwt-auth có thể hoạt động với Device model ta cần phải implements JWTSubject cho Device model. Và phải thêm 2 hàm phục vụ chức năng lấy các trường để tạo auth-token.

// app\Models\Device.php
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        // Khi nodejs nhận được chuỗi jwt và parse ra sẽ được dữ liệu trả về trong hàm này
        return [
            'id' => $this->id,
            'identify_code' => $this->identify_code,
            'guard' => 'device',
        ];
    }

Tạo route

Route đăng nhập, đăng xuất sẽ như sau:

Route::group(['prefix' => 'device', 'namespace' => 'Device'], function () {
    Route::get('session', '[email protected]');
    Route::post('login', '[email protected]');
    Route::post('logout', '[email protected]');
});

Tạo controller đăng nhập

Tạo controller App\Http\Controllers\Device\SessionController.

Ta cần phải tạo 3 hàm tương ứng với 3 chức năng:

  • Kiểm tra trạng thái đăng nhập (Hàm session)
  • Đăng nhập (Hàm login)
  • Đăng xuất (Hàm logout)

Dữ liệu trả về có các status sau:

  • login_in: Đã đăng nhập thành công vào hệ thống
  • not_login: Chưa đăng nhập
  • login_fail: Đăng nhập thất bại
  • logout_success: Đăng xuất thành công
const RESPON_STATUS = [
        'not_login' => 0,
        'login_in' => 1,
        'login_fail' => 2,
        'logout_success' => 3,
    ];

Hàm get session

Trả về dữ liệu đã login thành công hoặc là chưa login

    public function session(JWTAuth $auth)
    {
        if (Auth::guard($this->guard)->check()) {
            return $this->userWasAuthenticated($auth);
        }

        return $this->sessionResponse(self::RESPON_STATUS['not_login']);
    }

Dữ liệu trả về khi đã login

    protected function userWasAuthenticated($auth)
    {
        $user = Auth::guard($this->guard)->user();
        if ($user->isActive()) {
            // Lấy auth token từ user đã đăng nhập
            $authToken = $auth->fromUser($user);
            $result = $this->makeUserResult($user, $authToken);

            return $this->sessionResponse(self::RESPON_STATUS['login_in'], $result);
        }

        $this->logout();
        return $this->sessionResponse(self::RESPON_STATUS['login_fail']);
    }

Hàm login

Giúp device đăng nhập vào hệ thống

    public function login(Request $request, JWTAuth $auth)
    {
        $this->validateLogin($request);
        $credentials = $request->only('identify_code', 'password');

        if ($this->attemptLogin($request, $credentials)) {
            return $this->userWasAuthenticated($auth);
        }

        return $this->sessionResponse(self::RESPON_STATUS['login_fail']);
    }

Hàm logout

Hàm đăng xuất

    public function logout()
    {
        Auth::guard($this->guard)->logout();

        return $this->sessionResponse(self::RESPON_STATUS['logout_success']);
    }

Hàm sessionResponse

Trả về dữ liệu với token để Device có thể sử dụng cho việc gửi post request.

    protected function sessionResponse($status, $data = [], $statusCode = 200)
    {
        $data['status'] = $status;
        $data['token'] = csrf_token();
        return response()->json($data, $statusCode);
    }

Phần này khá đơn giản. Các bạn có thể đọc code full tại đây.

Bây giờ chỉ còn việc viết chương trình cho wemos thôi. Bắt đầu thực hiện từng bước một nào!

Chúng ta sẽ sử dụng thư viện esp8266-socket.io này để kết nối socket. Các bạn tải hoặc clone thư viện này về xong coppy vào thư mục /home/{user}/Arduino/libraries là có thể sử dụng được rồi.

Gửi request đăng nhập vào server laravel

Viết hàm gửi request và lưu cookies lại

Đầu tiên chúng ta cần phải biết một điều là Laravel sẽ giữ session đăng nhập bằng cookies. Nếu không lưu lại thì request sau bạn gửi sẽ thành session khác. Và tất nhiên khi mà bạn đăng nhập ở request trước rồi, request sau server vẫn coi là chưa đăng nhập nếu bạn không gửi cookies kèm theo. 😃

Chúng ta vẫn sử dụng các hàm waitForInputreadLine của bài trước nhé.

// Hàm gửi request
void sendRequest(String method, String host, int port, String path, String data) {
    char hostname[128];
    host.toCharArray(hostname, MAX_HOSTNAME_LEN);
    // Kết nối đến host và post
    if (!internets.connect(hostname, port)) {
        ECHO(F("Connect failed"));
        return;
    }

    // Tạo request
    String request = "";
    request += method;
    request += F(" /");
    request += path;
    request += F(" HTTP/1.1\r\n");
    request += F("Host: ");
    request += hostname;
    request += F("\r\n");
    request += F("Accept: application/json\r\n");
    // Thêm token vào header mặc định khởi tạo là rỗng. Sau khi gửi get session server trả về token thì mình sẽ lưu lại.
    request += F("X-CSRF-TOKEN: ");
    request += token;    // Biến toàn cục
    request += F("\r\n");
    request += F("Cookie: ");
    request += cookie; // cookie cũng sẽ được thêm khi parse response nhận được. Ban đầu là rỗng
    request += F("\r\n");
    request += ORIGIN;
    // Nếu có dữ liệu body thì phải thêm trường Content-Length vào header
    if (data.length() > 0) {
        request += F("Content-Length: ");
        request += data.length();
        request += F("\r\n");
    }
    request += F("Content-Type: application/json\r\n");
    request += F("Connection: keep-alive\r\n\r\n");
    // Nếu có dữ liệu body thì thêm body vào
    if (data.length() > 0) {
        request += data;
        request += F("\r\n");
    }

    ECHO(F("\r\n[sendRequest] Send request........................."));
    ECHO(request);
    ECHO(F("[sendRequest] .........................send request done\r\n"));

    // Gửi request đi
    internets.print(request);
    // Chờ response trả về
    if (!waitForInput()) {
        ECHO(F("[sendRequest] Time out"));
    }
}

// Thêm cookie mới vào chuỗi cookie cũ
void setCookie(String data) {
    int a = data.indexOf(";");
    String newCookie = data.substring(12, a);
    String newCookieKey = newCookie.substring(0, newCookie.indexOf("=")); // Lấy giá trị của key
    int oldCookieStart = cookie.indexOf(newCookieKey); // Kiểm tra xem key đã tồn tại không
    if (oldCookieStart >= 0) {
        // Nếu cookie mới đã tồn tại thì thay thế cookie cũ bằng cookie mới
        int oldCookieEnd = cookie.indexOf(";", oldCookieStart);
        String newCookies = cookie.substring(0, oldCookieStart);
        newCookies += newCookie;
        newCookies += cookie.substring(oldCookieEnd);
        cookie = newCookies;
    } else {
        // Nếu cookie mới chưa tồn tại thì nối vào chuỗi cookie cũ
        cookie += newCookie;
        cookie += ";";
    }
    ECHO("cookie");
    ECHO(cookie);
}

Dữ liệu gửi về sẽ ở dạng stream các byte. Bạn có thể hiểu là nó sẽ truyền từng byte một toàn bộ response, từ header đến body. Và wemos sẽ lưu vào bộ đệm. Mình có thể đọc lần lượt từng byte. Vì mình không biết có bao nhiêu byte được lưu trữ nên không thể đọc toàn bộ response trả về. Và cũng không nên làm như vậy vì rất tốn bộ nhớ. Mình phải đọc hết phần header thì mới có thể đọc đến body. Vì phần header không dùng nên mình sẽ viết hàm xóa header đi.

void eatHeader() {
    while (internets.available()) {
        // Đọc từng dòng
        readLine();
        // Nếu thấy 1 dòng trống, tức là đã hết dữ liệu header thì thoát ra.
        if (strlen(databuffer) == 0) {
            break;
        }
        String data = databuffer;
        // Nếu thấy key là Set-Cookie thì lưu cookie lại
        if (data.indexOf("Set-Cookie:") >=0) {
            setCookie(data);
        }
    }
}

Viết hàm stopConnect

Đây là 1 hàm rất quan trọng. Khi bạn gửi http request và xử lý xong thì nhớ gọi hàm stopConnect nhé. Hàm này sẽ xóa bộ đệm và ngắt kết nối với server.

stopConnect() {
    while (internets.available()) {
        readLine();
    }

    internets.stop();
    delay(100);
    ECHO(F("[stopConnect] Connect was stopped"));
    return true;
}

Viết hàm getSession

Hàm này có tác dụng kiểm tra xem Device đã đăng nhập hay chưa.

void getSession() {
    String path = "device/session";
    // Gửi request GET đến path device/session
    sendRequest(String("GET"), host, port, path, String(""));
    // Loại bỏ header
    eatHeader();
    // Đọc body
    String data = "";
    while (internets.available()) {
        readLine();
        data += String(databuffer);
    }
    JsonObject& root = jsonBuffer.parse(data);
    if (!root.success()) {
        Serial.println("Parse json failed");
        return;
    }
    // Lưu giá trị loginStatus
    int newStatus = root["status"];
    loginStatus = newStatus; // loginStatus là biến toàn cục
    // Lưu token lại
    String newToken = root["token"];
    token = newToken; // token là biến toàn cục
    if (loginStatus == 1) {
        Serial.println(F("User is login"));
    } else {
        Serial.println(F("User is not login"));
    }
}

Chạy thử hàm này xem có hoạt động đúng không nào:

Dữ liệu trả về hoàn toàn đúng như mong đợi. 😃

Viết hàm login

Ta thêm 2 biến toàn cục lưu identify_codepassword

String identifyCode = "be-ca-1";
String password = "12344321";

Tài khoản device này đã có trong sedder của server laravel. Nếu bạn chưa chạy seeder thì hãy chạy lệnh php artisan db:seed để tạo database test nhé.

Hàm login sẽ được viết như sau:

void login() {
    StaticJsonBuffer<DATA_BUFFER_LEN> jsonBuffer;
    String path = "device/login";
    String data = F("{\"identify_code\":\"");
    data += identifyCode;
    data += "\",\"password\":\"";
    data += devicePassword;
    data += "\"}";

    Serial.print(F("Data login: "));
    Serial.println(data);
    sendRequest(String("POST"), host, port, path, data);
    eatHeader();
    data = "";
    while (internets.available()) {
        readLine();
        data += String(databuffer);
    }
    JsonObject& root = jsonBuffer.parse(data);
    if (!root.success()) {
        Serial.println("Parse json failed");
        return;
    }
    int status = root["status"];
    String newAuthToken = root["auth_token"];
    authToken = newAuthToken;
    stopConnect();
}

Login thử xem thế nào nào.

Sau khi login xong getSession xem thế nào.

Kết quả như mong đợi. Dòng chữ "User is login" đã hiện ra.

Kết nối với socket.io

Bạn có thể tham khảo phần 3 của series này.

Phần này chỉ khác một cái là ta phải gửi chuỗi authenticate sau khi bắt tay xong. Và trước khi tạo kết nối socket.

Tạo thêm hàm authenticate vào thư viện.

Ở file SocketIOClient.h bạn thêm khai báo hàm:

// SocketIOClient.h

private:
    /* ..... */
    bool authenticate(String authToken);
    void sendRequestAuthenticate(String authToken);

Viết hàm authenticate và sendRequestAuthenticate

// SocketIOClient.cpp

bool SocketIOClient::authenticate(String authToken) {
    ECHO(authToken);
    if (authToken.length() == 0) {
        return true;
    }

    if (!beginConnect()) {
        return false;
    }

    sendRequestAuthenticate(authToken);

    if (!waitForInput()) {
        ECHO(F("[sendRequestAuthenticate] Time out"));
        return false;
    }

    if (!checkResponseStatus(200)) {
        ECHO(F("[sendRequestAuthenticate] Authenticate fail"));
        return false;
    }
    stopConnect();
}

void SocketIOClient::sendRequestAuthenticate(String authToken) {
    ECHO("[sendRequestAuthenticate] Authenticate with token: " + authToken);
    String body = String(authToken.length() + 31);
    body += ":42[\"authenticate\",{\"token\":\"";
    body += authToken;
    body += "\"}]";

    String request = "";
    request += F("POST /socket.io/?EIO=3&transport=polling&sid=");
    request += sid;
    request += F(" HTTP/1.1\r\n");
    if (port == 80) {
        request += F("Host: ");
        request += hostname;
        request += F("\r\n");
    } else {
        request += F("Host: ");
        request += hostname;
        request += F(":");
        request += port;
        request += F("\r\n");
    }

    request += ORIGIN;
    request += F("Content-Type: text/plain;charset=UTF-8\r\n");
    request += F("Content-Length: ");
    request += body.length();
    request += F("\r\n");
    request += F("Connexion: keep-alive\r\n\r\n");
    request += body;
    request += "\r\n\r\n";

    ECHO(F("\r\n[sendRequestAuthenticate] Send request........................."));
    ECHO(request);
    ECHO(F("[sendRequestAuthenticate] .........................send request done\r\n"));
    internets.print(request);
}

Tại hàm connect của thư viện ta thêm hàm authenticate vào như sau:

// SocketIOClient.cpp
bool SocketIOClient::connect(String thehostname, int theport, String authToken) {
    thehostname.toCharArray(hostname, MAX_HOSTNAME_LEN);
    port = theport;
    ECHO(F("[connect] Connect to host: "));
    ECHO(hostname);
    ECHO(F("[connect] Connect to port: "));
    ECHO(port);
    if (handshake() && authenticate(authToken) && connectViaSocket()) {
        return true;
    }

    return false;
}

Nhớ sửa lại phần khai báo hàm trong file SocketIOClient.h cho đúng nhé

// SocketIOClient.h

public:
    bool connect(String thehostname, int port = 80, String authToken = "");

Khởi tạo biến toàn cục socket

SocketIOClient socket;

Thêm chức năng kết nối socket

Bạn tham khảo phần này nhé. Mình sẽ không trình bày lại nữa. Bạn cần chú ý ở dòng socket.connect(host, port); ta thay thành socket.connect(host, port, authToken); với authToken là cái authToken chúng ta đã vất vả lấy được ở trên.

Các bạn có thể tham khảo code của mình tại đây.

Kết luận

Trên đây mình cùng các bạn đã xây dựng xong chương trình kết nối server cho wemos. Như vậy chúng ta đã đi được gần hết chặng đường làm bể cá thông minh rồi. Và chương trình của mình mỗi khi thay đổi lại phải kết nối với máy tính rồi nạp lại code. Thật phiền phức! Điều tất yếu phải xuất hiện 1 cách nào đó để việc update phần mềm được dễ dàng hơn. Phần tiếp theo mình sẽ chia sẻ cách update firmware qua mạng cho wemos. Các bạn hãy đón đọc nhé!