diff --git a/.env b/.env index 79a9fdf..aeb3313 100644 --- a/.env +++ b/.env @@ -44,3 +44,6 @@ ADMIN_KEY='123456' # jwt 签名密钥,公网部署时必须修改 JWT_SECRET='WECHAT_OFFICIAL_HELPER' + +# OAuth2.0 配置 +CLIENT_ID='wechat-official-helper' diff --git a/src/app.ts b/src/app.ts index e86013e..8a0eeaa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import { errorhandler, notFoundHandler } from './middlewares/error' import routes from './routes' import indexPage from './pages/index' import oauthPage from './pages/oauth' +import errorPage from './pages/error' const app = new Hono() @@ -25,6 +26,7 @@ app.notFound(notFoundHandler) app.route('/', indexPage) app.route('/oauth', oauthPage) +app.route('/error', errorPage) app.route('/', routes) __DEV__ && showRoutes(app, { diff --git a/src/env.ts b/src/env.ts index ca3c794..043d3ca 100644 --- a/src/env.ts +++ b/src/env.ts @@ -57,3 +57,6 @@ export const JWT_SECRET = process.env.JWT_SECRET || '' // 二维码地址 export const QRCODE_URL = process.env.QRCODE_URL || '' + +// OAuth2.0 配置 +export const CLIENT_ID = process.env.CLIENT_ID || '' diff --git a/src/pages/error.tsx b/src/pages/error.tsx new file mode 100644 index 0000000..9896da8 --- /dev/null +++ b/src/pages/error.tsx @@ -0,0 +1,35 @@ +import { Hono } from 'hono' +import { PropsWithChildren, FC } from 'hono/jsx' +import { StatusCode } from 'hono/utils/http-status' + +const app = new Hono() + +type Props = PropsWithChildren<{ + status: string | number + error?: string + error_description?: string +}> + +const ErrorPage: FC = (props) => { + const { status, error, error_description } = props + + return ( +
+
+

{status}

+

{error}

+

{error_description}

+
+
+ ) +} + +app.get('/', (c) => { + const { status = 400, error, error_description } = c.req.query() + return c.html( + , + Number(status) as StatusCode, + ) +}) + +export default app diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d549e7f..a5f9866 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,10 +3,11 @@ import { FC } from 'hono/jsx' import dayjs from 'dayjs' import { MoreThanOrEqual } from 'typeorm' import { Layout } from '@/layout/layout' -import { json2xml } from '@/utils/helper' +import { generateRandomString, json2xml } from '@/utils/helper' import winstonLogger from '@/utils/logger' import { getDataSource } from '@/db' import { VerifyCode } from '@/db/models/verify-code' +import { CLIENT_ID, OAUTH_REDIRECT_URL } from '@/env' const app = new Hono() @@ -17,7 +18,18 @@ type Props = { const Welcome: FC = (props) => { const { name, id } = props const isLogin = !!id - + const client_id = CLIENT_ID + const redirect_uri = OAUTH_REDIRECT_URL + const response_type = 'code' + const scope = 'user' + const state = generateRandomString(16) + const url = `/oauth?${new URLSearchParams({ + client_id, + redirect_uri, + response_type, + scope, + state, + })}` return (
@@ -28,7 +40,7 @@ const Welcome: FC = (props) => { {isLogin ?
您已登录
: - + @@ -43,7 +55,7 @@ app.all('/', async (c) => { let name = 'Hono' let id = 0 try { - const accessCode = c.req.query('accessCode') // 如果有 code,则验证是否有效,如果有效,则查询对应的用户信息 + const accessCode = c.req.query('code') // 如果有 code,则验证是否有效,如果有效,则查询对应的用户信息 if (accessCode) { winstonLogger.isDebugEnabled() && winstonLogger.debug(`accessCode: ${accessCode}`) // 由于本地就能获取到用户信息,所以不走获取 accessToken 的流程 diff --git a/src/pages/oauth.tsx b/src/pages/oauth.tsx index bf047fd..b72d5e0 100644 --- a/src/pages/oauth.tsx +++ b/src/pages/oauth.tsx @@ -1,14 +1,21 @@ import { Hono } from 'hono' -import { FC } from 'hono/jsx' +import { FC, PropsWithChildren } from 'hono/jsx' import { Layout } from '@/layout/layout' import { QRCODE_URL } from '@/env' -import { generateRandomString } from '@/utils/helper' const app = new Hono() +type Props = PropsWithChildren<{ + client_id: string + redirect_uri: string + response_type: string + scope?: string + state: string +}> + // 通过验证码登录的前端表单 -const OAuthLogin: FC = () => { - const state = generateRandomString(16) +const OAuthLogin: FC = (props) => { + const { client_id, redirect_uri, response_type, scope, state } = props return (
@@ -20,6 +27,10 @@ const OAuthLogin: FC = () => {

+ + + +
@@ -34,8 +45,15 @@ const OAuthLogin: FC = () => { ) } app.get('/', (c) => { + // 处理 OAuth 登录请求 + const { client_id, redirect_uri, response_type, scope, state } = c.req.query() + // - `response_type`: 必须。表示授权类型,常用的值有 `code`(授权码模式)和 `token`(隐式授权模式)。 + // - `client_id`: 必须。客户端标识符。 + // - `redirect_uri`: 可选。重定向 URI,用于将用户代理重定向回客户端。 + // - `scope`: 可选。请求的权限范围。 + // - `state`: 推荐。用于防止 CSRF 攻击的随机字符串。 return c.html( - , + , ) }) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 38936bf..15f4aad 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -7,10 +7,14 @@ import { User } from '@/db/models/user' import { getJwtToken, verifyPassword } from '@/utils/helper' import { VerifyCode } from '@/db/models/verify-code' import { jwtAuth } from '@/middlewares/auth' -import { OAUTH_REDIRECT_URL } from '@/env' +import { CLIENT_ID, OAUTH_REDIRECT_URL } from '@/env' import { createAccessCode } from '@/services/code' -const app = new Hono() +type Variables = { + redirect_uri: string +} + +const app = new Hono<{ Variables: Variables }>() // 账号密码登录 app.post('/login', async (c) => { @@ -66,12 +70,35 @@ app.post('/loginByOAuth', async (c) => { } else if (contentType === 'application/json') { body = await c.req.json() } - const { code, state, redirectUri } = body + const { code, client_id, redirect_uri, response_type, scope, state } = body + // - `code`: 必须。验证码。 + // - `response_type`: 必须。表示授权类型,常用的值有 `code`(授权码模式)和 `token`(隐式授权模式)。 + // - `client_id`: 必须。客户端标识符。 + // - `redirect_uri`: 可选。重定向 URI,用于将用户代理重定向回客户端。 + // - `scope`: 可选。请求的权限范围。 + // - `state`: 推荐。用于防止 CSRF 攻击的随机字符串。 + if (redirect_uri) { + c.set('redirect_uri', redirect_uri) + } + if (!OAUTH_REDIRECT_URL) { + return c.json({ error: 'access_denied', error_description: 'OAUTH_REDIRECT_URL 未配置', state }, 400) + } + // 检查请求参数 + if (!response_type || !client_id || !state) { + return c.json({ error: 'invalid_request', error_description: '缺失部分必要的参数', state }, 400) + } + if (response_type !== 'code' && response_type !== 'token') { + return c.json({ error: 'unsupported_response_type', state }, 400) + } + if (client_id !== CLIENT_ID) { + return c.json({ error: 'invalid_client', error_description: '无效的客户端', state }, 400) + } + const scene = 'login' const verifyCodeRepository = (await getDataSource()).getRepository(VerifyCode) const verifyCode = await verifyCodeRepository.findOne({ where: { code, scene, used: false, expiredAt: MoreThanOrEqual(dayjs().add(-5, 'minutes').toDate()) }, relations: ['user'] }) if (code !== verifyCode?.code) { - throw new HTTPException(400, { message: '验证码错误' }) + return c.json({ error: 'access_denied', error_description: '验证码错误', state }, 400) } verifyCode.used = true await verifyCodeRepository.save(verifyCode) @@ -80,14 +107,12 @@ app.post('/loginByOAuth', async (c) => { const accessCode = await createAccessCode(user) // 将授权码返回给客户端 const query = new URLSearchParams({ - accessCode: accessCode.code, + code: accessCode.code, state, }) - if (!OAUTH_REDIRECT_URL) { - throw new HTTPException(400, { message: 'OAUTH_REDIRECT_URL 未配置' }) - } + // 如果 redirectUri 是 OAUTH_REDIRECT_URL 的子域名,就使用 redirectUri,否则使用 OAUTH_REDIRECT_URL - const url = redirectUri?.startsWith(OAUTH_REDIRECT_URL) ? new URL(redirectUri) : new URL(OAUTH_REDIRECT_URL) + const url = redirect_uri?.startsWith(OAUTH_REDIRECT_URL) ? new URL(redirect_uri) : new URL(OAUTH_REDIRECT_URL) url.search = query.toString() const redirectUrl = url.toString() return c.redirect(redirectUrl, 302) @@ -95,11 +120,25 @@ app.post('/loginByOAuth', async (c) => { // 根据授权码获取获取 accessToken,本接口仅建议在后端调用 app.post('/getAccessToken', async (c) => { - const { accessCode } = await c.req.json() + const { code, grant_type, redirect_uri, client_id, client_secret, scope } = await c.req.json() + /** + - `grant_type`: 必须。表示授权类型,常用的值有 `authorization_code`、`password`、`client_credentials` 和 `refresh_token`。 + - `code`: 当 `grant_type=authorization_code` 时必须。授权码。 + - `redirect_uri`: 当 `grant_type=authorization_code` 时必须。重定向 URI,必须与授权请求中的 URI 一致。 + - `client_id`: 当客户端未使用客户端凭据时必须。客户端标识符。 + - `client_secret`: 当客户端使用客户端凭据时必须。客户端密钥。 + - `scope`: 可选。请求的权限范围。 + */ + if (grant_type !== 'authorization_code') { + return c.json({ error: 'unsupported_grant_type', error_description: '不支持的授权类型' }, 400) + } + if (client_id !== CLIENT_ID) { + return c.json({ error: 'invalid_client', error_description: '无效的客户端' }, 400) + } const verifyCodeRepository = (await getDataSource()).getRepository(VerifyCode) - const verifyCode = await verifyCodeRepository.findOne({ where: { code: accessCode, scene: 'access-code', used: false, expiredAt: MoreThanOrEqual(dayjs().add(-5, 'minutes').toDate()) }, relations: ['user'] }) + const verifyCode = await verifyCodeRepository.findOne({ where: { code, scene: 'access-code', used: false, expiredAt: MoreThanOrEqual(dayjs().add(-5, 'minutes').toDate()) }, relations: ['user'] }) if (!verifyCode) { - throw new HTTPException(400, { message: '授权码无效' }) + return c.json({ error: 'invalid_grant', error_description: '授权码无效' }, 400) } verifyCode.used = true await verifyCodeRepository.save(verifyCode) @@ -108,7 +147,9 @@ app.post('/getAccessToken', async (c) => { const token = await getJwtToken({ id: user.id }) return c.json({ message: '授权码正确', - data: token, + access_token: token, + token_type: 'Bearer', + expires_in: 7200, // 2 小时有效 }) })