目录
- Table of contents
- What will we learn
- Requirements
- Project Setup
- Twitter OAuth2 Implementation
- Finishing the web app
- Conclusion
我们会学到什么
在这里,我们将学习使用Twitter OAuth 2.0在最小工作全堆栈Web应用程序上实现身份验证。我们不会使用Passport.js或类似的库来处理我们的身份验证。结果,我们将更好地理解OAuth 2.0流。我们还将了解以下堆栈:
- express.js后端框架
- prisma要创建和登录用户,您可以真正使用任何与任何数据库通信。
- next.js,React.js框架,用于前端
- typescript(可选)JavaScript的类型安全
要求
任何具有JavaScript基本知识的人都可以与此博客一起遵循。
如果您已经有类似的项目设置,也可以直接跳到Twitter OAuth2 Implementation部分。
项目设置
首先,让我们在根目录中添加一个package.json
文件,然后添加以下内容:
{
"private": true,
"workspaces": [
"server",
"client"
],
"scripts": {
"client:dev": "yarn workspace client dev",
"server:dev": "yarn workspace server dev",
"client:add": "yarn workspace client add",
"server:add": "yarn workspace server add",
"migrate-db": "yarn workspace server prisma-migrate"
}
}
您可以在此目录中设置版本控件,但这是可选的。无论哪种方式,我们现在都会为我们的Web应用添加客户和服务器。
客户端设置
通过运行以下命令来制作next.js应用:
yarn create next-app --typescript client
如果要使用JavaScript,请跳过--typescript
标志。
这将在项目目录中创建一个client
文件夹。导航并删除我们不需要的文件,即client\styles\Home.module.css
和client\pages\api
。另外,让我们用以下内容替换client\pages\index.ts
中的所有代码:
import { NextPage } from "next";
const Home: NextPage = () => {
return (
<div className="column-container">
<p>Hello!</p>
</div>
);
};
export default Home;
使用我们的命令yarn client:dev
启动客户端,然后转到http://www.localhost:3000/的地址应显示一个网页,说Hello!
现在设置了前端,让我们继续前进。
服务器设置
制作一个名为server
的目录,并在项目目录中使用以下内容制作一个package.json
文件:
{
"name": "server",
"version": "1.0.0",
"scripts": {
"start": "node dist/index.js",
"build": "tsc --sourceMap false",
"build:watch": "tsc -w",
"start:watch": "nodemon dist/index.js",
"dev": "concurrently \"yarn build:watch\" \"yarn start:watch\" --names \"tsc,node\" -c \"blue,green\"",
"prisma-migrate": "prisma migrate dev",
"prisma-gen": "prisma generate"
}
}
在这里,我们添加了各种脚本,以帮助我们在开发阶段。我们将主要使用dev
和migrate-db
脚本。它们使我们能够以观察模式启动服务器,并让我们分别迁移数据库。现在,我们可以返回工作区目录,并使用我们的yarn server:add
添加软件包。因此是时候使用终端中使用以下命令安装所需依赖项了:
yarn server:add @prisma/client argon2 axios cookie-parser cors dotenv express jsonwebtoken
yarn server:add -D nodemon prisma typescript concurrently @types/cookie-parser @types/cors @types/express @types/jsonwebtoken @types/node
安装我们需要的依赖项后,制作几个文件以最小运行Express Server:
-
server/tsconfig.json
根据首选项编辑或跳过如果不使用Typescript
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"lib": [
"esnext", "esnext.asynciterable"
],
"strict": true,
"skipLibCheck": true,
"sourceMap": true,
"declaration": true,
"moduleResolution": "node",
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"incremental": false,
"baseUrl": "./src",
"watch": false,
"removeComments": true,
"outDir": "./dist",
"rootDir": "./src"
},
"types": ["node"],
"include": ["./src/**/*.ts"],
}
-
server/src/index.ts
聆听服务器。
import { CLIENT_URL, SERVER_PORT } from "./config";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
const app = express();
const origin = [CLIENT_URL];
app.use(cookieParser());
app.use(cors({
origin,
credentials: true
}))
app.get("/ping", (_, res) => res.json("pong"));
app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
-
server/src/config.ts
导出一些恒定配置变量
import { PrismaClient } from "@prisma/client"
export const CLIENT_URL = process.env.CLIENT_URL!
export const SERVER_PORT = process.env.SERVER_PORT!
export const prisma = new PrismaClient()
-
server/.env
为服务器设置端口,客户端URL和数据库URL。如果您要使用版本控制,请确保忽略此文件
DATABASE_URL=postgres://postgres:postgres@localhost:5432/twitter-oauth2
CLIENT_URL=http://www.localhost:3000
SERVER_PORT=3001
-
server/prisma/schema.prisma
让Prisma处理数据库结构
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserType {
local
twitter
}
model User {
id String @id @default(uuid())
name String
username String @unique
type UserType @default(local)
}
现在使用yarn migrate-db
命令迁移数据库,然后我们可以使用yarn server:dev
运行服务器。
我们现在应该能够在http://localhost:3001/ping
上ping我们的服务器Twitter OAuth2实现
我们准备通过Twitter OAuth 2.0实现身份验证到我们的应用中。我们将遵循this方法。
首先,我们必须在Twitter上制作一个应用程序。
设置Twitter用户身份验证设置
前往twitter's developer portal,在项目中以任何名称制作项目和开发应用程序。 Twitter将向您展示所需的东西。从Twitter获得这些应用程序可能需要几个小时才能获得批准。完成后,请转到应用程序的设置页面以设置一些必要的字段。
根据您的应用程序设置或编辑用户身份验证。
由于我只需要阅读此最小Web应用程序的配置文件信息,因此这些是我使用的设置:
保存Twitter客户ID和客户端秘密。
注意:http://www.localhost:3000起作用,但不起作用,但不起作用。
因此,我在两个网站中添加了www.
。
客户
前端身份验证按钮
现在我们在客户端中添加按钮,这将导致我们的后端进行身份验证。
为此,我们需要使用有效的Twitter oauth url getter函数和一个按钮到达URL。
import twitterIcon from "../public/twitter.svg";
import Image from "next/image";
const TWITTER_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ" // give your twitter client id here
// twitter oauth Url constructor
function getTwitterOauthUrl() {
const rootUrl = "https://twitter.com/i/oauth2/authorize";
const options = {
redirect_uri: "http://www.localhost:3001/oauth/twitter", // client url cannot be http://localhost:3000/ or http://127.0.0.1:3000/
client_id: TWITTER_CLIENT_ID,
state: "state",
response_type: "code",
code_challenge: "y_SfRG4BmOES02uqWeIkIgLQAlTBggyf_G7uKT51ku8",
code_challenge_method: "S256",
scope: ["users.read", "tweet.read", "follows.read", "follows.write"].join(" "), // add/remove scopes as needed
};
const qs = new URLSearchParams(options).toString();
return `${rootUrl}?${qs}`;
}
// the component
export function TwitterOauthButton() {
return (
<a className="a-button row-container" href={getTwitterOauthUrl()}>
<Image src={twitterIcon} alt="twitter icon" />
<p>{" twitter"}</p>
</a>
);
}
注意:为简单起见,我们正在硬编码
code_challenge
和code_verifier
。您可以随机生成它。
在client\components\TwitterOauthButton.tsx
中添加上述代码后,我们将在Path client\public\twitter.svg
上添加一个Twitter SVG图标(来自this之类的在线资源)。
然后,我们将在主页上导入组件:
import { TwitterOauthButton } from "../components/TwitterOauthButton";
const Home: NextPage = () => {
return (
<div className="column-container">
<p>Hello!</p>
<TwitterOauthButton />
</div>
);
};
这就是之后的样子:
单击Twitter图标将导致我们进入Twitter页面,我们可以在其中授权应用程序:
当然,单击authorize app
按钮导致Cannot GET /oauth/twitter
响应,因为我们尚未实现后端。
我查询
让我们请求从前端登录的用户通过钩子client\hooks\useMeQuery.ts
:
import { useEffect, useState } from "react";
import axios, { AxiosResponse } from "axios";
export type User = {
id: string;
name: string;
username: string;
type: "local" | "twitter";
};
export function useMeQuery() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<User | null>(null);
useEffect(() => {
setLoading(true);
axios
.get<any, AxiosResponse<User>>(`http://www.localhost:3001/me`, {
withCredentials: true,
})
.then((v) => {
if (v.data) setData(v.data);
})
.catch(() => setError("Not Authenticated"))
.finally(() => setLoading(false));
}, []);
return { error, data, loading };
}
这将为我们的最小应用做出足够的工作。我们将使用它来确定渲染内容。如果我们从挂钩中获取用户,我们将渲染用户名。否则,我们将渲染Login with Twitter
按钮
import type { NextPage } from "next";
import { TwitterOauthButton } from "../components/TwitterOauthButton";
import { useMeQuery } from "../hooks/useMeQuery";
const Home: NextPage = () => {
const { data: user } = useMeQuery();
return (
<div className="column-container">
<p>Hello!</p>
{user ? (// user present so only display user's name
<p>{user.name}</p>
) : (// user not present so prompt to login
<div>
<p>You are not Logged in! Login with:</p>
<TwitterOauthButton />
</div>
)}
</div>
);
};
export default Home;
以上是最终的client\pages\index.tsx
的样子。转到http://www.localhost:3000,并在加载页面时检查浏览器的网络窗口。您应该看到ME查询在那里执行。
它的404,因为我们还没有在后端实施
造型
让我们通过修改client\styles\globals.css
文件时添加一些基本样式:
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
.column-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.row-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.a-button {
border: 2px solid grey;
border-radius: 5px;
}
.a-button:hover {
background-color: #111133;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
body {
color: white;
background: black;
}
}
这就是我们在客户端要做的。最终主页应该看起来像这样:
服务器
正如我们从前端看到的那样,我们需要在服务器中实现GET /oauth/twitter
路由,以使应用程序工作的一部分。看看twitter documentation揭示了我们需要执行的步骤,以便我们可以阅读范围中提到的信息here。
这些步骤总结如下:
- 获取访问令牌
- 从访问令牌获取Twitter用户
- 在我们的数据库中提高用户
- 创建cookie,以便服务器可以验证用户
- 用cookie重定向到客户
注意:只有前两个步骤与Twitter Oauth实现有关
让我们添加一个文件server\src\oauth2.ts
,我们将在其中添加与OAUTH相关的代码。我们将通过在此处定义功能来完成上述步骤:
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code; // getting the code if the user authorized the app
// 1. get the access token with the code
// 2. get the twitter user using the access token
// 3. upsert the user in our db
// 4. create cookie so that the server can validate the user
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
在做任何一个之前,请确保我们在.env
文件中的Twitter Oauth客户端秘密。我们还将在其中添加一个JWT秘密,以便我们可以加密发送给客户的cookie。最终的.env
文件应该看起来像这样:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/twitter-oauth2
CLIENT_URL=http://www.localhost:3000
SERVER_PORT=3001
JWT_SECRET=put-your-jwt-secret-here
TWITTER_CLIENT_SECRET=put-your-twitter-client-secret-here
使用代码获取访问令牌
添加以下代码(注释的行解释了他们的工作)以获取访问令牌:
// add your client id and secret here:
const TWITTER_OAUTH_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ";
const TWITTER_OAUTH_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET!;
// the url where we get the twitter access token from
const TWITTER_OAUTH_TOKEN_URL = "https://api.twitter.com/2/oauth2/token";
// we need to encrypt our twitter client id and secret here in base 64 (stated in twitter documentation)
const BasicAuthToken = Buffer.from(`${TWITTER_OAUTH_CLIENT_ID}:${TWITTER_OAUTH_CLIENT_SECRET}`, "utf8").toString(
"base64"
);
// filling up the query parameters needed to request for getting the token
export const twitterOauthTokenParams = {
client_id: TWITTER_OAUTH_CLIENT_ID,
// based on code_challenge
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
redirect_uri: `http://www.localhost:3001/oauth/twitter`,
grant_type: "authorization_code",
};
// the shape of the object we should recieve from twitter in the request
type TwitterTokenResponse = {
token_type: "bearer";
expires_in: 7200;
access_token: string;
scope: string;
};
// the main step 1 function, getting the access token from twitter using the code that twitter sent us
export async function getTwitterOAuthToken(code: string) {
try {
// POST request to the token url to get the access token
const res = await axios.post<TwitterTokenResponse>(
TWITTER_OAUTH_TOKEN_URL,
new URLSearchParams({ ...twitterOauthTokenParams, code }).toString(),
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BasicAuthToken}`,
},
}
);
return res.data;
} catch (err) {
console.error(err);
return null;
}
}
从访问令牌中获取Twitter用户
类似的代码以使用户从访问令牌中获取:
// the shape of the response we should get
export interface TwitterUser {
id: string;
name: string;
username: string;
}
// getting the twitter user from access token
export async function getTwitterUser(accessToken: string): Promise<TwitterUser | null> {
try {
// request GET https://api.twitter.com/2/users/me
const res = await axios.get<{ data: TwitterUser }>("https://api.twitter.com/2/users/me", {
headers: {
"Content-type": "application/json",
// put the access token in the Authorization Bearer token
Authorization: `Bearer ${accessToken}`,
},
});
return res.data.data ?? null;
} catch (err) {
console.error(err);
return null;
}
}
检查他们是否工作
让我们看看他们是否成功地吸引了我们的用户。在server\src\oauth2.ts
文件中添加所有代码后,应该看起来像这样:
import { CLIENT_URL } from "./config";
import axios from "axios";
import { Request, Response } from "express";
// add your client id and secret here:
const TWITTER_OAUTH_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ";
const TWITTER_OAUTH_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET!;
// the url where we get the twitter access token from
const TWITTER_OAUTH_TOKEN_URL = "https://api.twitter.com/2/oauth2/token";
// we need to encrypt our twitter client id and secret here in base 64 (stated in twitter documentation)
const BasicAuthToken = Buffer.from(`${TWITTER_OAUTH_CLIENT_ID}:${TWITTER_OAUTH_CLIENT_SECRET}`, "utf8").toString(
"base64"
);
// filling up the query parameters needed to request for getting the token
export const twitterOauthTokenParams = {
client_id: TWITTER_OAUTH_CLIENT_ID,
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
redirect_uri: `http://www.localhost:3001/oauth/twitter`,
grant_type: "authorization_code",
};
// the shape of the object we should recieve from twitter in the request
type TwitterTokenResponse = {
token_type: "bearer";
expires_in: 7200;
access_token: string;
scope: string;
};
// the main step 1 function, getting the access token from twitter using the code that the twitter sent us
export async function getTwitterOAuthToken(code: string) {
try {
// POST request to the token url to get the access token
const res = await axios.post<TwitterTokenResponse>(
TWITTER_OAUTH_TOKEN_URL,
new URLSearchParams({ ...twitterOauthTokenParams, code }).toString(),
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BasicAuthToken}`,
},
}
);
return res.data;
} catch (err) {
return null;
}
}
// the shape of the response we should get
export interface TwitterUser {
id: string;
name: string;
username: string;
}
// getting the twitter user from access token
export async function getTwitterUser(accessToken: string): Promise<TwitterUser | null> {
try {
// request GET https://api.twitter.com/2/users/me
const res = await axios.get<{ data: TwitterUser }>("https://api.twitter.com/2/users/me", {
headers: {
"Content-type": "application/json",
// put the access token in the Authorization Bearer token
Authorization: `Bearer ${accessToken}`,
},
});
return res.data.data ?? null;
} catch (err) {
return null;
}
}
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code;
// 1. get the access token with the code
const TwitterOAuthToken = await getTwitterOAuthToken(code);
console.log(TwitterOAuthToken);
if (!TwitterOAuthToken) {
// redirect if no auth token
return res.redirect(CLIENT_URL);
}
// 2. get the twitter user using the access token
const twitterUser = await getTwitterUser(TwitterOAuthToken.access_token);
console.log(twitterUser);
if (!twitterUser) {
// redirect if no twitter user
return res.redirect(CLIENT_URL);
}
// 3. upsert the user in our db
// 4. create cookie so that the server can validate the user
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
导入并将路由添加到我们的Express App:
app.get("/ping", (_, res) => res.json("pong"));
// activate twitterOauth function when visiting the route
app.get("/oauth/twitter", twitterOauth);
app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
现在运行客户端和服务器,并查看服务器控制台,如果我们单击前端的Twitter按钮并授权应用程序。
。我们现在成功从Twitter获得了用户!
最重要的部分,即从Twitter获取用户。现在我们可以完成我们的项目。
完成Web应用程序
让我们完成GET /oauth/twitter
工作所需的其余步骤。由于它们与OAuth无关,因此我将在server\src\config.ts
文件中添加功能。
import { PrismaClient, User } from "@prisma/client"
import { CookieOptions, Response } from "express";
import { TwitterUser } from "./oauth2";
import jwt from "jsonwebtoken";
export const CLIENT_URL = process.env.CLIENT_URL!
export const SERVER_PORT = process.env.SERVER_PORT!
export const prisma = new PrismaClient()
// step 3
export function upsertUser(twitterUser: TwitterUser) {
// create a new user in our database or return an old user who already signed up earlier
return prisma.user.upsert({
create: {
username: twitterUser.username,
id: twitterUser.id,
name: twitterUser.name,
type: "twitter",
},
update: {
id: twitterUser.id,
},
where: { id: twitterUser.id},
});
}
// JWT_SECRET from our environment variable file
export const JWT_SECRET = process.env.JWT_SECRET!
// cookie name
export const COOKIE_NAME = 'oauth2_token'
// cookie setting options
const cookieOptions: CookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
sameSite: "strict"
}
// step 4
export function addCookieToRes(res: Response, user: User, accessToken: string) {
const { id, type } = user;
const token = jwt.sign({ // Signing the token to send to client side
id,
accessToken,
type
}, JWT_SECRET);
res.cookie(COOKIE_NAME, token, { // adding the cookie to response here
...cookieOptions,
expires: new Date(Date.now() + 7200 * 1000),
});
}
导入功能并在server\src\oauth2.ts
中使用它们:
import { prisma, CLIENT_URL, addResCookie } from "./config";
...
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code;
// 1. get the access token with the code
const twitterOAuthToken = await getTwitterOAuthToken(code);
if (!twitterOAuthToken) {
// redirect if no auth token
return res.redirect(CLIENT_URL);
}
// 2. get the twitter user using the access token
const twitterUser = await getTwitterUser(twitterOAuthToken.access_token);
if (!twitterUser) {
// redirect if no twitter user
return res.redirect(CLIENT_URL);
}
// 3. upsert the user in our db
const user = await upsertUser(twitterUser)
// 4. create cookie so that the server can validate the user
addCookieToRes(res, user, twitterOAuthToken.access_token)
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
注意:为简单起见,我们正在cookie中发送访问令牌。对于Web应用程序,我们应该将其存储在更安全的地方,例如数据库。
最后,在server\src\index.ts
文件中添加me
查询。
import { CLIENT_URL, COOKIE_NAME, JWT_SECRET, prisma, SERVER_PORT } from "./config";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import jwt from 'jsonwebtoken'
import { getTwitterUser, twitterOauth } from "./oauth2";
import { User } from "@prisma/client";
const app = express();
const origin= [CLIENT_URL];
app.use(cookieParser());
app.use(cors({
origin,
credentials: true
}))
app.get("/ping", (_, res) => res.json("pong"));
type UserJWTPayload = Pick<User, 'id'|'type'> & {accessToken: string}
app.get('/me', async (req, res)=>{
try {
const token = req.cookies[COOKIE_NAME];
if (!token) {
throw new Error("Not Authenticated");
}
const payload = await jwt.verify(token, JWT_SECRET) as UserJWTPayload;
const userFromDb = await prisma.user.findUnique({
where: { id: payload?.id },
});
if (!userFromDb) throw new Error("Not Authenticated");
if (userFromDb.type === "twitter") {
if (!payload.accessToken) {
throw new Error("Not Authenticated");
}
const twUser = await getTwitterUser(payload.accessToken);
if (twUser?.id !== userFromDb.id) {
throw new Error("Not Authenticated");
}
}
res.json(userFromDb)
} catch (err) {
res.status(401).json("Not Authenticated")
}
})
// activate twitterOauth function when visiting the route
app.get("/oauth/twitter", twitterOauth);
app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
现在完成了!让我们看看当我们单击客户端上的Twitter按钮并在此处授权应用程序时会发生什么。
我们现在在其中看到我们的Twitter用户名而不是Twitter按钮,这表明me
查询已成功执行。结果,我们现在通过Twitter OAuth 2.0在我们的最小全堆栈Web应用程序中使用了一个有效的用户身份验证系统。
结论
感谢您的阅读! This是具有所有代码的GitHub存储库。找到更多有趣的事情,可以使用Twitter API here。可以找到通过Twitter OAuth 2.0实现身份验证的另一个示例,可以找到here。
撰写