Skip to content

Commit

Permalink
feat: 重构 OAuth 登录为标准 OAuth2.0 流程
Browse files Browse the repository at this point in the history
fix #4
  • Loading branch information
CaoMeiYouRen committed Oct 11, 2024
1 parent 7180448 commit ee1e0a3
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 22 deletions.
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ ADMIN_KEY='123456'

# jwt 签名密钥,公网部署时必须修改
JWT_SECRET='WECHAT_OFFICIAL_HELPER'

# OAuth2.0 配置
CLIENT_ID='wechat-official-helper'
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -25,6 +26,7 @@ app.notFound(notFoundHandler)

app.route('/', indexPage)
app.route('/oauth', oauthPage)
app.route('/error', errorPage)
app.route('/', routes)

__DEV__ && showRoutes(app, {
Expand Down
3 changes: 3 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''
35 changes: 35 additions & 0 deletions src/pages/error.tsx
Original file line number Diff line number Diff line change
@@ -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> = (props) => {
const { status, error, error_description } = props

return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div className="text-center">
<h1 className="text-5xl font-extrabold text-gray-800 mt-4">{status}</h1>
<p className="mt-2 text-lg text-gray-600">{error}</p>
<p className="mt-2 text-lg text-gray-600">{error_description}</p>
</div>
</div>
)
}

app.get('/', (c) => {
const { status = 400, error, error_description } = c.req.query()
return c.html(
<ErrorPage {...{ status, error, error_description }} />,
Number(status) as StatusCode,
)
})

export default app
20 changes: 16 additions & 4 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -17,7 +18,18 @@ type Props = {
const Welcome: FC<Props> = (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 (
<Layout title="主页">
<div className="flex flex-col items-center justify-start min-h-screen bg-gray-100 pt-16">
Expand All @@ -28,7 +40,7 @@ const Welcome: FC<Props> = (props) => {
{isLogin ?
<div className="mt-6 text-center text-green-600 font-semibold text-lg">您已登录</div>
:
<a href="/oauth" className="mt-6 w-full max-w-sm">
<a href={url} className="mt-6 w-full max-w-sm">
<button type="button" className="w-full flex justify-center py-3 px-6 border border-transparent rounded-lg shadow-lg text-base font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
登录
</button>
Expand All @@ -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 的流程
Expand Down
28 changes: 23 additions & 5 deletions src/pages/oauth.tsx
Original file line number Diff line number Diff line change
@@ -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> = (props) => {
const { client_id, redirect_uri, response_type, scope, state } = props
return (
<Layout title="验证码登录">
<div className="max-w-md mx-auto p-6 bg-white shadow-md rounded-lg">
Expand All @@ -20,6 +27,10 @@ const OAuthLogin: FC = () => {
</p>
</div>
<form action="/auth/loginByOAuth" method="post">
<input type="hidden" name="client_id" value={client_id} />
<input type="hidden" name="redirect_uri" value={redirect_uri} />
<input type="hidden" name="response_type" value={response_type} />
<input type="hidden" name="scope" value={scope} />
<input type="hidden" name="state" value={state} />
<div className="mb-4">
<label htmlFor="code" className="block text-sm font-medium text-gray-700">验证码</label>
Expand All @@ -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(
<OAuthLogin />,
<OAuthLogin {...{ client_id, redirect_uri, response_type, scope, state }} />,
)
})

Expand Down
67 changes: 54 additions & 13 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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

Check warning on line 73 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'scope' is assigned a value but never used

Check warning on line 73 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'scope' is assigned a value but never used

Check warning on line 73 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Release

'scope' is assigned a value but never used
// - `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)
Expand All @@ -80,26 +107,38 @@ 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)
})

// 根据授权码获取获取 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()

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'redirect_uri' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'client_secret' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'scope' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'redirect_uri' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'client_secret' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Test

'scope' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Release

'redirect_uri' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Release

'client_secret' is assigned a value but never used

Check warning on line 123 in src/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Release

'scope' is assigned a value but never used
/**
- `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)
Expand All @@ -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 小时有效
})
})

Expand Down

0 comments on commit ee1e0a3

Please sign in to comment.