Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

บทนำ

ในบทความนี้เราจะสร้างบริการ REST API ที่ตรวจสอบสิทธิ์ (authenticated) อย่างสมบูรณ์ แม้จะเป็นขั้นพื้นฐาน โดยใช้ Next.js API Routes และ Upstash Redis ซึ่งเราจะใช้เป็นระบบจัดเก็บ/แคชที่เร็วมาก สำหรับข้อมูล การยืนยันตัวตนผู้ใช้ และการจัดการ JWT โปรดทราบว่าโปรเจกต์นี้จะไม่มี frontend แต่จะเปิดเผย API เท่านั้น ที่สามารถสืบค้นได้ด้วยไคลเอ็นต์หลากหลาย

ข้อกำหนดเบื้องต้น

เพื่อทำตามบทช่วยสอนนี้ คุณต้องมี:

  • บัญชี Upstash — สมัครฟรีได้ที่นี่
  • ความรู้พื้นฐานเกี่ยวกับ Redis
  • ความรู้พื้นฐานเกี่ยวกับ Next.js API Routes
  • ความรู้พื้นฐานเกี่ยวกับขั้นตอนการยืนยันตัวตนและการอนุมัติ (authentication and authorization workflow)
  • เครื่องมือที่คุณชอบสำหรับส่ง HTTP request

Upstash Redis คืออะไร

Upstash เป็น ฐานข้อมูลในหน่วยความจำแบบ Serverless บนคลาวด์ ที่พัฒนาจาก Redis เราจะใช้มันเก็บข้อมูลที่ API จะเสิร์ฟ เก็บฐานข้อมูลผู้ใช้ และเก็บ user tokens ด้วย

เราจะสร้างอะไร

เราจะเขียน REST API service ที่อนุญาตให้แอปพลิเคชันไคลเอ็นต์ขอข้อมูล (ในกรณีนี้คือรายการหนัง) เราจะรักษาความปลอดภัยของจุดสิ้นสุดด้วย JWT เขียนบริการ API ล็อกอินเพื่อรับ token และนำ workflow refresh token มาใช้ด้วย

เราจะไม่เน้น การพัฒนาไคลเอ็นต์ (เพราะเราสร้างบริการ 'unopinionated') แต่เราจะให้ข้อกำหนดของบริการของเรา เพื่อให้ใครก็ตามสร้างไคลเอ็นต์ได้

ที่เก็บโค้ดและสาธิต

หากต้องการทำตาม อาจจะ clone repository โปรเจกต์

Source on GitHub

ลองสาธิตได้ที่ URL ต่อไปนี้:

https://upstash-dwov9jbiq-popland.vercel.app/api/auth/signin

เพื่อเชื่อมต่อบริการ ส่ง HTTP POST Request พร้อม username (me@home.org) และ password (password) ตามตัวอย่าง (ใช้ Postman):

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

ตั้งค่าฐานข้อมูล Redis

ก่อนอื่น ต้องสมัคร Upstash Redis (แผนฟรีพอสำหรับทดสอบ) หลังล็อกอินในคอนโซล สร้างฐานข้อมูลใหม่:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

กด "Create database" ตั้งชื่อ MovieManager และตั้งเป็น Global ตอนนี้เพิ่มข้อมูลจำลองด้วย Upstash CLI

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

เราจะเพิ่มหนังเป็น Redis hash (คืออ็อบเจ็กต์) โดยคำสั่ง HMSET:

hmset movie:'Dr. Strangelove' director 'Stanley Kubrick' year 1964
hmset movie:'2001: A Space Odyssey' director 'Stanley Kubrick' year 1968
hmset movie:'Pulp Fiction' director 'Quentin Tarantino' year 1994
hmset movie:'Django Unchained' director 'Quentin Tarantino' year 2012

เราจะเพิ่มผู้ใช้ที่ได้รับอนุญาตให้เข้าถึงข้อมูล ผู้ใช้ก็เป็น Redis hash เช่นกัน:

hmset user:'me@home.org' password $2b$10$zctxUVDyy3jzvSp68oKpMOnkyra4R.NzOFVh9aii3Y43X7XtetoyK level 0

โปรดทราบ: รหัสผ่านเข้ารหัสด้วย bcrypt (plain text คือ password) ปกติผู้ใช้ที่ต้องเข้าถึง API จะลงทะเบียนผ่านเว็บไซต์ (หรือรับข้อมูลประจำตัวจากเว็บ) ในตัวอย่างนี้เราไม่มี endpoint ลงทะเบียน

ทุกคำสั่งใน Upstash CLI ควรตอบ OK หากทุกอย่างถูกต้อง และไปที่ Data Browser เลือก Hash จะเห็นรายการข้อมูลที่ใส่เข้าไป

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

ขั้นตอนการอนุมัติ (Authorization workflow)

ตามที่กล่าวมา จุดสิ้นสุดของเราไม่ได้เข้าถึงได้แบบสาธารณะ จึงต้องมีวิธียืนยันตัวตนและอนุมัติผู้ใช้ สำหรับการยืนยันตัวตน เรามี endpoint ล็อกอิน สำหรับการอนุมัติ จุดสิ้นสุดที่ป้องกันไว้ต้องการ Authorization Header ส่งมาพร้อมคำขอ นี่คือรายละเอียด workflow:

  • ผู้ใช้ขอ endpoint sign-in ส่ง username และ password
  • เซิร์ฟเวอร์พยายามยืนยันตัวตนผู้ใช้ หากถูกต้อง เซิร์ฟเวอร์สร้างและส่งคืน JWT (JSON Web Token) และ Refresh Token, Refresh Token ก็ถูกเก็บใน Upstash Redis instance ของเราด้วย
  • ไคลเอ็นต์รับ tokens กลับมา และเก็บไว้ที่ใดที่หนึ่ง (เป็นหน้าที่ของไคลเอ็นต์ว่าจะเก็บอย่างไร/ที่ไหน)
  • ไคลเอ็นต์ขอ endpoint ที่ป้องกันไว้ ส่ง JWT ใน Header
  • เซิร์ฟเวอร์รับ JWT ตรวจสอบ และหากผ่าน ส่งคืนข้อมูลที่ไคลเอ็นต์ขอ
  • เมื่อ JWT หมดอายุหรือใกล้หมดอายุ ไคลเอ็นต์สามารถขอ JWT ใหม่โดยไม่ต้องล็อกอินใหม่ โดยส่ง Refresh Token ไปที่ endpoint เฉพาะ
  • เซิร์ฟเวอร์รับ Refresh Token ตรวจสอบ และหากผ่าน ออก JWT ใหม่และ Refresh Token ใหม่ ส่งคืนให้ไคลเอ็นต์ และเก็บ Refresh Token ใหม่ซ้ำ

JWT และ Refresh Token มีรูปแบบเดียวกัน เก็บข้อมูลเกือบเหมือนกัน แต่ใช้ key ต่างกัน (ตั้งค่าใน .env) และหมดอายุต่างกัน: JWT อายุสั้น (เพราะใช้บ่อยสุดใน session หากถูกดักจับควรหมดอายุเร็ว) และ Refresh Token อายุยาว ระยะเวลาขึ้นกับความปลอดภัยที่ต้องการ ปกติ JWT หมดอายุภายใน 1 ชั่วโมง Refresh Token อาจทนได้ 1 เดือน หากทั้งสองหมดอายุ ผู้ใช้ต้องล็อกอินใหม่

ตั้งค่าโปรเจกต์

เมื่อเสร็จกับฐานข้อมูล Upstash Redis เริ่มต้นโปรเจกต์ สร้างโปรเจกต์ Next.js ใหม่:

npx create-next-app upstash-jwt

เข้าโฟลเดอร์ upstash-jwt ที่เพิ่งสร้าง และติดตั้งโมดูลที่ต้องการ:

npm i bcrypt jsonwebtoken @upstash/redis

สร้างไฟล์ .env.local เก็บ keys และ填入ข้อมูลที่ถูกต้อง

SECRET_TOKEN=
SECRET_RTOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

สร้าง SECRET_TOKEN และ SECRET_RTOKEN สำหรับสร้าง JWT คีย์เหล่านี้ต้องเก็บเป็นความลับและสุ่มยากต่อการเดา ใช้ string 64-bit to Hex ได้ ดึง UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN จากคอนโซล Upstash, แท็บ Details, ส่วน Rest API:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

ตอนนี้เราสามารถวางแผน endpoint ได้:

POST /auth/signin ล็อกอินผู้ใช้ ต้องการ email และ password ส่งเป็น JSON object {"email":"email", "password": "password"} ส่งคืน JSON object พร้อมข้อมูลผู้ใช้ JWT และ Refresh Token

GET /movies/ ส่งคืนรายการหนังเป็น JSON object ต้องการ JWT ที่ถูกต้อง ส่งใน header รูปแบบ: Authorization: Bearer xxx

GET /movies/$ID ส่งคืนรายละเอียดหนังที่มี id $ID

POST /auth/refresh สร้างและส่งคืน JWT ใหม่ Refresh Token ต้องส่งเป็นพารามิเตอร์ refreshToken

โค้ดสำหรับ API routes

เริ่มจาก endpoint sign in สร้างไฟล์ pages/api/auth/signin.js ดังนี้:

import bcrypt from "bcrypt";
import { addToList, generateAccessToken, generateRefreshToken, redis } from "../../../utils";
export default async (req, res) => {
    if (req.method === "GET") {
        res.status(405).send("Not Allowed");
    } else {
        console.log(req.body.user);
        try {
            const user = await redis.hgetall(`user:${req.body.user}`);
            if (user) {
                const validPassword = bcrypt.compare(req.body.password, user.password);
                if (validPassword) {
                    const token = generateAccessToken(req.body.user, user.level);
                    const refreshToken = generateRefreshToken(req.body.user, user.level);
                    const refresh = await addToList(req.body.user, refreshToken);
                    const content = { user: req.body.user, level: user.level };
                    res.status(200).json({ message: "Logged in", content: content, JWT: token, refresh: refreshToken });
                } else {
                    res.status(400).json({ error: "Invalid Password" });
                }
            } else {
                res.status(401).json({ error: "User not found" });
            }
        } catch (error) {
            res.status(500).send("Internal Server Error");
        }
    }
};

Endpoint sign-in รับเฉพาะ POST พร้อมสองพารามิเตอร์: user และ password ก่อนอื่นตรวจสอบว่าผู้ใช้อยู่ใน Redis ด้วย:

const user = await redis.hgetall(`user:${req.body.user}`);

หากมีผู้ใช้ เปรียบเทียบรหัสผ่านที่เข้ารหัส:

const validPassword = bcrypt.compare(req.body.password, user.password);

หากรหัสผ่านตรง สมมติว่าผู้ใช้ยืนยันตัวตนสำเร็จ ส่งคืน JWT และ refresh Token เก็บ refresh token ใน Redis instance ของเรา ใช้ฟังก์ชันบางอย่างในไฟล์ภายนอกชื่อ utils.js

เป็นหน้าที่ของไคลเอ็นต์ที่จะเก็บ tokens ที่ได้รับ ใช้เพื่อการอนุมัติเมื่อต้องการ และ refresh เมื่อหมดอายุ

เรามีฟังก์ชันสร้าง Token generateAccessToken, สร้าง refresh Token generateRefreshToken, และเก็บ refresh Token ใน Redis addToList ไฟล์ utils.js นี้ยังเก็บฟังก์ชัน utility อื่นๆ และ reference (เช่น การเชื่อมต่อ Redis, การตรวจสอบและ refresh token ฯลฯ):

import { Redis } from "@upstash/redis";
import jwt from "jsonwebtoken";
export const redis = new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL,
    token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export function generateAccessToken(username, email, level) {
    return jwt.sign(
        { user: username, email: email, level: level },
        process.env.SECRET_TOKEN,
        { expiresIn: "1h" },
    );
}
export function generateRefreshToken(username, email, level) {
    return jwt.sign(
        { user: username, email: email, level: level },
        process.env.SECRET_RTOKEN,
        { expiresIn: "30d" },
    );
}
export async function addToList(user, refresher) {
    try {
        await redis.hset("refresh:" + user, { refresh: refresher });
    } catch (error) {
        console.log(error);
    }
}
export async function tokenRefresh(refreshtoken, res) {
    var decoded = "";
    try {
        decoded = jwt.verify(refreshtoken, process.env.SECRET_RTOKEN);
    } catch (error) {
        return res.status(401).send("Can't refresh. Invalid Token");
    }
    if (decoded) {
        try {
            const rtoken = await redis.hget("refresh:" + decoded.user, "refresh");
            console.log(rtoken);
            if (rtoken !== refreshtoken) {
                return res.status(401).send("Can't refresh. Invalid Token");
            } else {
                const user = await redis.hgetall(`user:${decoded.user}`);
                console.log(user);
                const token = generateAccessToken(decoded.user, user.level);
                const refreshToken = generateRefreshToken(decoded.user, user.level);
                const refresh = await addToList(decoded.user, refreshToken);
                const content = { user: decoded.user, level: user.level };
                return { message: "Token Refreshed", content: content, JWT: token, refresh: refreshToken };
            }
        } catch (error) {
            console.log(error);
        }
    }
}
export async function verifyToken(token, res) {
    try {
        const decoded = jwt.verify(token, process.env.SECRET_TOKEN);
        return decoded;
    } catch (err) {
        return res.status(405).send("Token is invalid");
    }
}

ตอนนี้ใช้เครื่องมือ (เช่น Postman) ทดสอบกระบวนการ sign-in โดย POST ไป https://localhost:3000/api/auth/signin ส่ง username (me@home.org) และ password (password) ควรได้ JSON object กลับมาพร้อมรายละเอียดผู้ใช้ JWT และ Refresh Token:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

หากทุกอย่างถูกต้อง ในฐานข้อมูล Redis ควรเห็น Hash entry ใหม่สำหรับ Refresh Token ที่เพิ่งสร้าง:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

ต่อไป เราเสร็จสิ้นกระบวนการยืนยันตัวตน โดยเขียน route refresh token refresh.js

import { redis, tokenRefresh } from "../../../utils";
export default async (req, res) => {
    if (req.method === "GET") {
        res.status(405).send("Not Allowed");
    } else {
        console.log(req.body.refresh);
        const refresp = await tokenRefresh(req.body.refresh, res);
        res.status(200).json(refresp);
    }
};

มันใช้ฟังก์ชัน tokenRefresh จาก utils.js เริ่มจากตรวจสอบว่า token ถูกต้องและถอดรหัสได้ จากนั้นตรวจสอบใน Redis ว่าผู้ใช้มี refresh token (ที่เราเก็บไว้ก่อนหน้าด้วย addToList) หากทุกอย่างถูกต้อง สร้าง JWT ใหม่, refresh Token ใหม่ (และเก็บใน Redis อีกครั้ง) ส่งทุกอย่างกลับให้ไคลเอ็นต์

ทดสอบ endpoint นี้ด้วยเครื่องมือ POST ไป https://localhost:3000/api/auth/refresh ส่ง refresh Token เป็นพารามิเตอร์:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

ตอนนี้ ไคลเอ็นต์สมมติของเราสามารถล็อกอินและ refresh token ได้ ลองดูว่า token ใช้ทำคำขอที่ตรวจสอบสิทธิ์ได้อย่างไร

สร้าง API route ใหม่: api/movies/[[...id]].js ใช้สำหรับดึงรายการหนังและรายละเอียดหนัง:

import { redis, verifyToken } from "../../../utils";
export default async (req, res) => {
    var id;
    console.log(req.query);
    if (req.query.id) {
        id = req.query.id[0];
    }
    var decoded = "";
    const authHeader = req.headers["authorization"];
    const token = authHeader && authHeader.split(" ")[1];
    if (!token) {
        return res.status(403).send("A token is required for authentication");
    } else {
        decoded = await verifyToken(token, res);
    }
    if (decoded) {
        if (id) {
            try {
                const result = await redis.hgetall(id);
                console.log(result);
                return res.status(200).json(result);
            } catch (error) {
                return res.status(500).send("Internal Server Error");
            }
        } else {
            try {
                const result = await redis.scan(0, { match: "movie:*" });
                return res.status(200).json(result);
            } catch (error) {
                return res.status(500).send("Internal Server Error");
            }
        }
    }
};

ใช้ฟังก์ชัน verifyToken จาก utils.js เราจำกัดการเข้าถึง API endpoint ให้เฉพาะผู้ใช้ที่ให้ Token ถูกต้อง เราทำตัวอย่าง query สองแบบ แรกดึงรายการหนัง

const result = await redis.scan(0, { match: 'movie:*' });

และอีกอันดึงรายละเอียดหนังเดียว ตาม id param ใน URL request:

const result = await redis.hgetall(id);

ทั้งสองคำขอขึ้นอยู่กับสถานะผู้ใช้ที่ตรวจสอบผ่าน verifyToken แต่คุณผสมผสานได้ เช่นรายการสาธารณะ แต่วรายละเอียดป้องกันไว้ เนื่องจากเรายังมี level เก็บในผู้ใช้ (และใน token) จึงสามารถมีระดับการอนุมัติมากกว่านี้ได้ ลองดึงรายการหนัง:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

และรายละเอียดหนังเดี่ยว:

สร้าง API ที่ปลอดภัยและเร็วด้วย Next.js Routes และ Upstash Redis

มุมมองของไคลเอ็นต์ (Client perspective)

ตามที่กล่าว เราโฟกัสแค่ส่วนเซิร์ฟเวอร์ นี่คือขอบเขตหลักของ API ควรเป็น abstract ไม่ใช่เว็บไซต์ วิธีที่ไคลเอ็นต์ขอข้อมูล (ภาษาโปรแกรม, ไลบรารี ฯลฯ) เป็นสิทธิ์ของนักพัฒนาไคลเอ็นต์ เราให้รายการ endpoints, สิ่งที่ endpoints คาดหวัง, และสิ่งที่ endpoints ส่งคืน การจัดการข้อมูล, หน่วงเวลา refresh ฯลฯ เป็นกลยุทธ์ของไคลเอ็นต์

ขั้นตอนต่อไป

นี่แค่ตัวอย่างพื้นฐานของ workflow API ที่ป้องกันไว้ จากนี้ปรับปรุงได้อย่างไม่รู้จบ: เพิ่มประสิทธิภาพการจัดเก็บข้อมูลบน Redis, ปรับปรุงความปลอดภัยล็อกอินโดยเก็บข้อมูลผู้ใช้บน Redis instance อื่น, ตรวจสอบและ validate ข้อมูลที่ผู้ใช้ส่งก่อนทุกอย่าง, เพิ่ม endpoint, ส่งคืนข้อมูลในรูปแบบ GraphQL, สร้างไคลเอ็นต์สำหรับ API, จำกัดการเข้าถึงสูงสุดจำนวนครั้งต่อชั่วโมง, ใช้ level จำกัดการเข้าถึง... การขยายและปรับปรุงไม่มีที่สิ้นสุด!