@zvonimirsun/iszy-nuxt-auth-layer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/app/stores/user.ts +200 -0
- package/nuxt.config.ts +21 -0
- package/package.json +54 -0
- package/server/api/[...].ts +3 -0
- package/server/api/auth/check.get.ts +43 -0
- package/server/api/auth/login.post.ts +62 -0
- package/server/api/auth/logout.post.ts +39 -0
- package/server/api/oauth/[provider]/bind.get.ts +19 -0
- package/server/api/oauth/[provider]/callback.get.ts +99 -0
- package/server/api/oauth/[provider]/index.get.ts +10 -0
- package/server/middleware/session.ts +3 -0
- package/server/plugins/redisStorage.ts +14 -0
- package/server/utils/authFetch.ts +164 -0
- package/server/utils/random.ts +5 -0
- package/server/utils/sessionStore.ts +120 -0
- package/server/utils/stateStore.ts +23 -0
- package/shared/types/auth.ts +19 -0
- package/shared/utils/usePublicConfig.ts +4 -0
- package/shared/utils/user.ts +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZvonimirSun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ISZY Nuxt 认证 Layer
|
|
2
|
+
|
|
3
|
+
用于 ISZY Nuxt 应用的共享认证 layer,抽取前端 BFF、Redis session、登录登出、认证检查和 OAuth 回调等通用能力。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 基于 Redis 的 httpOnly session cookie。
|
|
8
|
+
- session 轮换与短 TTL tombstone,降低并发刷新 token 时的竞态影响。
|
|
9
|
+
- 后端 API 代理工具,自动注入 access token,并在 401 时尝试 refresh token。
|
|
10
|
+
- 本地登录、登出、登录态检查和 OAuth 回调路由。
|
|
11
|
+
- 通用用户 store、公开用户类型和用户信息裁剪工具。
|
|
12
|
+
|
|
13
|
+
## 使用方式
|
|
14
|
+
|
|
15
|
+
安装依赖:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @zvonimirsun/iszy-nuxt-auth-layer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
在 Nuxt 应用中继承该 layer:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
export default defineNuxtConfig({
|
|
25
|
+
extends: ['@zvonimirsun/iszy-nuxt-auth-layer'],
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
消费方应用需要按部署环境配置 `runtimeConfig.public.apiOrigin`、Redis 连接信息和 session cookie 选项。
|
|
30
|
+
|
|
31
|
+
## 运行时配置
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export default defineNuxtConfig({
|
|
35
|
+
runtimeConfig: {
|
|
36
|
+
public: {
|
|
37
|
+
apiOrigin: '',
|
|
38
|
+
title: '',
|
|
39
|
+
features: {
|
|
40
|
+
publicRegister: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
redis: {
|
|
44
|
+
host: '',
|
|
45
|
+
port: 6379,
|
|
46
|
+
password: undefined,
|
|
47
|
+
},
|
|
48
|
+
session: {
|
|
49
|
+
cookieName: 'NUXT_SESSION_ID',
|
|
50
|
+
maxAge: '7d',
|
|
51
|
+
domain: '',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 应用差异
|
|
58
|
+
|
|
59
|
+
该 layer 只提供通用认证链路,不接管具体应用的业务权限:
|
|
60
|
+
|
|
61
|
+
- `iszy-admin` 仍应在消费方保留 admin/superadmin 鉴权和 403 页面语义。
|
|
62
|
+
- `iszy-tools-next` 仍应在消费方保留工具权限、工具目录刷新和设置页展示逻辑。
|
|
63
|
+
- authentik 接入会在后续版本扩展,本首版只保留现有本地登录与 OAuth 基础能力。
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { Device, ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { PublicSimpleUser } from '#shared/types/auth'
|
|
3
|
+
import { acceptHMRUpdate, defineStore } from 'pinia'
|
|
4
|
+
|
|
5
|
+
interface LoginAttemptFailureData {
|
|
6
|
+
code: 'LOGIN_FAILED'
|
|
7
|
+
failedCount: number
|
|
8
|
+
remainingAttempts: number
|
|
9
|
+
maxAttempts: number
|
|
10
|
+
windowSeconds: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface LoginBanFailureData {
|
|
14
|
+
code: 'LOGIN_BANNED'
|
|
15
|
+
retryAfterSeconds: number
|
|
16
|
+
bannedUntil: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LoginFailureData = LoginAttemptFailureData | LoginBanFailureData
|
|
20
|
+
type LoginResultData = PublicSimpleUser | LoginFailureData
|
|
21
|
+
|
|
22
|
+
type ProfileFetcher = <T>(request: string, opts?: {
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
}) => Promise<T>
|
|
25
|
+
|
|
26
|
+
const appFetch = $fetch as unknown as <T>(request: string, opts?: Record<string, unknown>) => Promise<T>
|
|
27
|
+
|
|
28
|
+
export const useUserStore = defineStore('user', () => {
|
|
29
|
+
const profilePulled = ref(false)
|
|
30
|
+
const profile = ref<PublicSimpleUser>()
|
|
31
|
+
const logged = computed(() => !!profile.value)
|
|
32
|
+
|
|
33
|
+
let pullProfilePromise: Promise<boolean> | null = null
|
|
34
|
+
let pullProfileAbortController: AbortController | null = null
|
|
35
|
+
|
|
36
|
+
async function login(payload: {
|
|
37
|
+
userName: string
|
|
38
|
+
password: string
|
|
39
|
+
}) {
|
|
40
|
+
const { userName, password } = payload
|
|
41
|
+
if (!userName || !password) {
|
|
42
|
+
throw new Error('请输入用户名和密码')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const res = await appFetch<ResultDto<LoginResultData>>('/api/auth/login', {
|
|
47
|
+
method: 'post',
|
|
48
|
+
body: {
|
|
49
|
+
userName: userName.trim(),
|
|
50
|
+
password,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (res.success) {
|
|
55
|
+
profilePulled.value = true
|
|
56
|
+
updateProfile(res.data as PublicSimpleUser)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
removeProfile()
|
|
61
|
+
throw new Error(formatLoginFailureMessage(res.message, res.data))
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
removeProfile()
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatLoginFailureMessage(message: string, data?: LoginResultData) {
|
|
70
|
+
const fallbackMessage = message || '登录失败'
|
|
71
|
+
if (!data || !('code' in data)) {
|
|
72
|
+
return fallbackMessage
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (data.code === 'LOGIN_BANNED') {
|
|
76
|
+
return '登录失败次数过多,请稍后再试。'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return fallbackMessage
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function logout() {
|
|
83
|
+
const res = await appFetch<ResultDto<void>>('/api/auth/logout', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (res?.success) {
|
|
88
|
+
removeProfile()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new Error(res?.message || '登出失败')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function pullProfile(force = false, fetcher: ProfileFetcher = appFetch) {
|
|
96
|
+
if (profilePulled.value && !force) {
|
|
97
|
+
return logged.value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (force) {
|
|
101
|
+
pullProfileAbortController?.abort()
|
|
102
|
+
pullProfileAbortController = null
|
|
103
|
+
pullProfilePromise = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (pullProfilePromise && !force) {
|
|
107
|
+
return pullProfilePromise
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
pullProfileAbortController = new AbortController()
|
|
111
|
+
pullProfilePromise = (async () => {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetcher<ResultDto<{
|
|
114
|
+
logged: boolean
|
|
115
|
+
profile?: PublicSimpleUser
|
|
116
|
+
}>>('/api/auth/check', {
|
|
117
|
+
signal: pullProfileAbortController!.signal,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (res?.success && res.data?.logged) {
|
|
121
|
+
updateProfile(res.data.profile)
|
|
122
|
+
profilePulled.value = true
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
removeProfile()
|
|
127
|
+
profilePulled.value = true
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if ((error as Error).name !== 'AbortError') {
|
|
132
|
+
removeProfile()
|
|
133
|
+
profilePulled.value = true
|
|
134
|
+
}
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
pullProfileAbortController = null
|
|
139
|
+
pullProfilePromise = null
|
|
140
|
+
}
|
|
141
|
+
})()
|
|
142
|
+
|
|
143
|
+
return pullProfilePromise
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function updateProfile(data?: PublicSimpleUser) {
|
|
147
|
+
profile.value = data
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function removeProfile() {
|
|
151
|
+
updateProfile(undefined)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function thirdPartyUnbind(type: string) {
|
|
155
|
+
await appFetch<ResultDto<void>>('/api/oauth/unbind', {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
body: {
|
|
158
|
+
provider: type,
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
await pullProfile(true)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function getDevices(): Promise<Device[]> {
|
|
165
|
+
const res = await appFetch<ResultDto<Device[]>>('/api/auth/devices')
|
|
166
|
+
return res.data || []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function removeDevice({ deviceId, other }: {
|
|
170
|
+
deviceId?: string
|
|
171
|
+
all?: boolean
|
|
172
|
+
other?: boolean
|
|
173
|
+
}) {
|
|
174
|
+
await appFetch('/api/auth/logout', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
query: {
|
|
177
|
+
deviceId,
|
|
178
|
+
other,
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
profilePulled,
|
|
185
|
+
profile,
|
|
186
|
+
logged,
|
|
187
|
+
login,
|
|
188
|
+
logout,
|
|
189
|
+
pullProfile,
|
|
190
|
+
updateProfile,
|
|
191
|
+
removeProfile,
|
|
192
|
+
thirdPartyUnbind,
|
|
193
|
+
getDevices,
|
|
194
|
+
removeDevice,
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if (import.meta.hot) {
|
|
199
|
+
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
|
|
200
|
+
}
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
runtimeConfig: {
|
|
3
|
+
public: {
|
|
4
|
+
title: '',
|
|
5
|
+
apiOrigin: '',
|
|
6
|
+
features: {
|
|
7
|
+
publicRegister: false,
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
redis: {
|
|
11
|
+
host: '',
|
|
12
|
+
port: 6379,
|
|
13
|
+
password: undefined,
|
|
14
|
+
},
|
|
15
|
+
session: {
|
|
16
|
+
cookieName: 'NUXT_SESSION_ID',
|
|
17
|
+
maxAge: '7d',
|
|
18
|
+
domain: '',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zvonimirsun/iszy-nuxt-auth-layer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"description": "ISZY Nuxt 应用共享认证 layer。",
|
|
7
|
+
"author": "ZvonimirSun",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+ssh://git@github.com/ZvonimirSun/iszy-nuxt-auth-layer.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ZvonimirSun/iszy-nuxt-auth-layer#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ZvonimirSun/iszy-nuxt-auth-layer/issues"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./nuxt.config.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"app",
|
|
22
|
+
"server",
|
|
23
|
+
"shared",
|
|
24
|
+
"nuxt.config.ts",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@zvonimirsun/iszy-common": "^1.2.0",
|
|
30
|
+
"ms": "^2.1.3"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@pinia/nuxt": ">=0.11.0",
|
|
34
|
+
"nuxt": ">=4.0.0",
|
|
35
|
+
"pinia": ">=3.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@nuxt/eslint": "^1.15.2",
|
|
39
|
+
"@pinia/nuxt": "0.11.3",
|
|
40
|
+
"@types/ms": "^2.1.0",
|
|
41
|
+
"@types/node": "^25.9.1",
|
|
42
|
+
"nuxt": "^4.4.7",
|
|
43
|
+
"pinia": "^3.0.4",
|
|
44
|
+
"typescript": "^6.0.3",
|
|
45
|
+
"vue-tsc": "^3.3.3"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"postinstall": "nuxt prepare",
|
|
52
|
+
"typecheck": "nuxt typecheck"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { PublicSimpleUser } from '#shared/types/auth'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event): Promise<ResultDto<{
|
|
5
|
+
logged: boolean
|
|
6
|
+
profile?: PublicSimpleUser
|
|
7
|
+
}>> => {
|
|
8
|
+
const session = await getRedisSession(event)
|
|
9
|
+
if (!session) {
|
|
10
|
+
return {
|
|
11
|
+
success: true,
|
|
12
|
+
data: {
|
|
13
|
+
logged: false,
|
|
14
|
+
},
|
|
15
|
+
message: '未登录',
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const res = await authFetch<ResultDto<PublicUser>>(event, '/user/me')
|
|
21
|
+
if (res.success && res.data) {
|
|
22
|
+
return {
|
|
23
|
+
success: true,
|
|
24
|
+
data: {
|
|
25
|
+
logged: true,
|
|
26
|
+
profile: toPublicSimpleUser(res.data),
|
|
27
|
+
},
|
|
28
|
+
message: '已登录',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// fall through to the unauthenticated response below
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
success: true,
|
|
38
|
+
data: {
|
|
39
|
+
logged: false,
|
|
40
|
+
},
|
|
41
|
+
message: '未登录',
|
|
42
|
+
}
|
|
43
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { FetchError } from 'ofetch'
|
|
3
|
+
import type { PublicSimpleUser } from '#shared/types/auth'
|
|
4
|
+
|
|
5
|
+
interface LoginFailureData {
|
|
6
|
+
code: 'LOGIN_FAILED' | 'LOGIN_BANNED'
|
|
7
|
+
failedCount?: number
|
|
8
|
+
remainingAttempts?: number
|
|
9
|
+
maxAttempts?: number
|
|
10
|
+
windowSeconds?: number
|
|
11
|
+
retryAfterSeconds?: number
|
|
12
|
+
bannedUntil?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default defineEventHandler(async (event): Promise<ResultDto<PublicSimpleUser | LoginFailureData>> => {
|
|
16
|
+
const body = await readBody<{
|
|
17
|
+
userName: string
|
|
18
|
+
password: string
|
|
19
|
+
}>(event)
|
|
20
|
+
|
|
21
|
+
if (!body.userName || !body.password) {
|
|
22
|
+
throw createError({
|
|
23
|
+
statusCode: 400,
|
|
24
|
+
message: '用户名和密码不能为空',
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const res = await authFetch<ResultDto<{
|
|
30
|
+
access_token: string
|
|
31
|
+
refresh_token: string
|
|
32
|
+
profile: PublicUser
|
|
33
|
+
}>>(event, '/auth/login', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
body: {
|
|
36
|
+
username: body.userName.trim(),
|
|
37
|
+
password: body.password,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (res.success) {
|
|
42
|
+
await setRedisSession(event, {
|
|
43
|
+
access_token: res.data!.access_token,
|
|
44
|
+
refresh_token: res.data!.refresh_token,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...res,
|
|
50
|
+
data: res.data?.profile ? toPublicSimpleUser(res.data.profile) : undefined,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
await destroyRedisSession(event)
|
|
55
|
+
const errorData = (error as FetchError<ResultDto<LoginFailureData>>).data
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
message: errorData?.message || '登录失败',
|
|
59
|
+
data: errorData?.data,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { FetchError } from 'ofetch'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event): Promise<ResultDto<void>> => {
|
|
5
|
+
const query = getQuery<{
|
|
6
|
+
deviceId?: string
|
|
7
|
+
other?: string
|
|
8
|
+
}>(event)
|
|
9
|
+
|
|
10
|
+
const session = await getRedisSession(event)
|
|
11
|
+
if (!session) {
|
|
12
|
+
return {
|
|
13
|
+
success: true,
|
|
14
|
+
message: '已登出',
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await authFetch(event, '/auth/logout', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
query,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const errorCode = (error as FetchError)?.response?.status
|
|
26
|
+
if (errorCode && errorCode !== 401) {
|
|
27
|
+
throw error
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!query.other && !query.deviceId) {
|
|
32
|
+
await destroyRedisSession(event)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
success: true,
|
|
37
|
+
message: '已登出',
|
|
38
|
+
}
|
|
39
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const { apiOrigin } = usePublicConfig()
|
|
5
|
+
const { provider } = event.context.params as { provider: string }
|
|
6
|
+
const url = getRequestURL(event)
|
|
7
|
+
|
|
8
|
+
const res = await authFetch<ResultDto<string>>(event, '/oauth/code', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
})
|
|
11
|
+
const code = res.data!
|
|
12
|
+
const state = random()
|
|
13
|
+
|
|
14
|
+
await setState(state, {
|
|
15
|
+
isBind: true,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return sendRedirect(event, `${apiOrigin}/oauth/${provider}/bind?state=${state}&client=${encodeURIComponent(url.origin)}&access_token=${code}`)
|
|
19
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
|
|
3
|
+
function renderCallbackPage(title: string, origin: string, body: string, payload: { success: boolean, message?: string }) {
|
|
4
|
+
return `
|
|
5
|
+
<head>
|
|
6
|
+
<title>${title}</title>
|
|
7
|
+
<meta charset="UTF-8">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
${body}
|
|
11
|
+
<script>
|
|
12
|
+
if (window.opener && !window.opener.closed) {
|
|
13
|
+
window.opener.postMessage(${JSON.stringify(payload)}, '${origin}');
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
</body>
|
|
17
|
+
`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default defineEventHandler(async (event) => {
|
|
21
|
+
const { title } = usePublicConfig()
|
|
22
|
+
const url = getRequestURL(event)
|
|
23
|
+
const query = getQuery(event)
|
|
24
|
+
const state = query.state as string
|
|
25
|
+
|
|
26
|
+
if (!state) {
|
|
27
|
+
throw createError({
|
|
28
|
+
statusCode: 400,
|
|
29
|
+
message: '缺少 state 参数',
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stateData = await getState(state)
|
|
34
|
+
if (!stateData) {
|
|
35
|
+
throw createError({
|
|
36
|
+
statusCode: 400,
|
|
37
|
+
message: '无效的 state 参数',
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await removeState(state)
|
|
42
|
+
|
|
43
|
+
if (stateData.isBind) {
|
|
44
|
+
const error = query.error as string | undefined
|
|
45
|
+
if (error) {
|
|
46
|
+
return renderCallbackPage(`${title} - 绑定失败`, url.origin, error, {
|
|
47
|
+
success: false,
|
|
48
|
+
message: error,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return renderCallbackPage(`${title} - 绑定成功`, url.origin, '验证成功', {
|
|
53
|
+
success: true,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const code = query.code as string
|
|
58
|
+
const error = query.error as string | undefined
|
|
59
|
+
let errorMessage = error || (!code ? '缺少 code 参数' : '')
|
|
60
|
+
|
|
61
|
+
if (!errorMessage) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await authFetch<ResultDto<{
|
|
64
|
+
access_token: string
|
|
65
|
+
refresh_token: string
|
|
66
|
+
profile: PublicUser
|
|
67
|
+
}>>(event, '/oauth/token', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
query: {
|
|
70
|
+
access_token: code,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (!res.success) {
|
|
75
|
+
errorMessage = '获取 token 失败'
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await setRedisSession(event, {
|
|
79
|
+
access_token: res.data!.access_token,
|
|
80
|
+
refresh_token: res.data!.refresh_token,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
errorMessage = '获取 token 失败'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (errorMessage) {
|
|
90
|
+
return renderCallbackPage(`${title} - 登录失败`, url.origin, errorMessage, {
|
|
91
|
+
success: false,
|
|
92
|
+
message: errorMessage,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return renderCallbackPage(`${title} - 登录成功`, url.origin, '登录成功', {
|
|
97
|
+
success: true,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
const { apiOrigin } = usePublicConfig()
|
|
3
|
+
const { provider } = event.context.params as { provider: string }
|
|
4
|
+
const url = getRequestURL(event)
|
|
5
|
+
const state = random()
|
|
6
|
+
|
|
7
|
+
await setState(state, {})
|
|
8
|
+
|
|
9
|
+
return sendRedirect(event, `${apiOrigin}/oauth/${provider}?state=${state}&client=${encodeURIComponent(url.origin)}`)
|
|
10
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import redisDriver from 'unstorage/drivers/redis'
|
|
2
|
+
|
|
3
|
+
export default defineNitroPlugin(() => {
|
|
4
|
+
const storage = useStorage()
|
|
5
|
+
const redisConfig = useRuntimeConfig().redis
|
|
6
|
+
|
|
7
|
+
const driver = redisDriver({
|
|
8
|
+
host: redisConfig.host,
|
|
9
|
+
port: redisConfig.port,
|
|
10
|
+
password: redisConfig.password,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
storage.mount('redis', driver)
|
|
14
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { PublicUser, ResultDto } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
import type { NitroFetchRequest, TypedInternalResponse } from 'nitropack'
|
|
4
|
+
import type { FetchError } from 'ofetch'
|
|
5
|
+
import type { SessionData } from '#shared/types/auth'
|
|
6
|
+
import { getProxyRequestHeaders } from 'h3'
|
|
7
|
+
|
|
8
|
+
export async function proxyFetch(event: H3Event) {
|
|
9
|
+
const sessionId = getSessionId(event)
|
|
10
|
+
const { apiOrigin } = usePublicConfig()
|
|
11
|
+
const target = apiOrigin + event.path.slice(4)
|
|
12
|
+
|
|
13
|
+
const headers = getForwardRequestHeaders(event)
|
|
14
|
+
headers['accept-encoding'] = 'identity'
|
|
15
|
+
|
|
16
|
+
const body = ['GET', 'HEAD'].includes(event.method) ? undefined : await readRawBody(event)
|
|
17
|
+
|
|
18
|
+
const doRequest = async () => {
|
|
19
|
+
if (sessionId) {
|
|
20
|
+
const sessionData = await getRedisSession(event)
|
|
21
|
+
if (sessionData) {
|
|
22
|
+
headers.authorization = `Bearer ${sessionData.access_token}`
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return fetch(target, {
|
|
26
|
+
method: event.method,
|
|
27
|
+
headers,
|
|
28
|
+
body,
|
|
29
|
+
duplex: 'half',
|
|
30
|
+
redirect: 'manual',
|
|
31
|
+
} as RequestInit & { duplex: 'half' })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let res = await doRequest()
|
|
35
|
+
if (sessionId && res.status === 401) {
|
|
36
|
+
try {
|
|
37
|
+
useRedisSession(event, await refreshWithLock(sessionId, async () => refreshToken(event)))
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
await destroyRedisSession(event)
|
|
41
|
+
return pipeResponse(event, res)
|
|
42
|
+
}
|
|
43
|
+
res = await doRequest()
|
|
44
|
+
}
|
|
45
|
+
return pipeResponse(event, res)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function authFetch<T = unknown>(event: H3Event, ...params: Parameters<typeof $fetch>): Promise<TypedInternalResponse<NitroFetchRequest, T>> {
|
|
49
|
+
const sessionId = getSessionId(event)
|
|
50
|
+
const { apiOrigin } = usePublicConfig()
|
|
51
|
+
const [url, opts = {}] = params
|
|
52
|
+
const headers: Record<string, string> = {
|
|
53
|
+
...getForwardRequestHeaders(event),
|
|
54
|
+
...(opts.headers as Record<string, string> | undefined),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
opts.headers = headers
|
|
58
|
+
opts.baseURL = apiOrigin
|
|
59
|
+
const fetcher = $fetch as unknown as (request: NitroFetchRequest, options?: typeof opts) => Promise<T>
|
|
60
|
+
|
|
61
|
+
const doRequest = async () => {
|
|
62
|
+
if (sessionId) {
|
|
63
|
+
const sessionData = await getRedisSession(event)
|
|
64
|
+
if (sessionData) {
|
|
65
|
+
headers.authorization = `Bearer ${sessionData.access_token}`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return fetcher(url, opts)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await doRequest() as TypedInternalResponse<NitroFetchRequest, T>
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (sessionId && (error as FetchError)?.response?.status === 401) {
|
|
76
|
+
try {
|
|
77
|
+
useRedisSession(event, await refreshWithLock(sessionId, async () => refreshToken(event)))
|
|
78
|
+
return await doRequest() as TypedInternalResponse<NitroFetchRequest, T>
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
await destroyRedisSession(event)
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function refreshToken(event: H3Event): Promise<SessionData> {
|
|
90
|
+
const sessionData = await getRedisSession(event)
|
|
91
|
+
if (!sessionData) {
|
|
92
|
+
throw new Error('REFRESH_FAILED')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { apiOrigin } = usePublicConfig()
|
|
96
|
+
const res = await $fetch<ResultDto<{
|
|
97
|
+
access_token: string
|
|
98
|
+
refresh_token: string
|
|
99
|
+
profile: PublicUser
|
|
100
|
+
}>>(`${apiOrigin}/auth/refresh`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
...getForwardRequestHeaders(event),
|
|
104
|
+
Authorization: `Bearer ${sessionData.refresh_token}`,
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (!res.success) {
|
|
109
|
+
throw new Error('REFRESH_FAILED')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return rotateRedisSession(event, {
|
|
113
|
+
access_token: res.data!.access_token,
|
|
114
|
+
refresh_token: res.data!.refresh_token,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const locks = new Map<string, Promise<SessionData>>()
|
|
119
|
+
|
|
120
|
+
async function refreshWithLock(sessionId: string, fn: () => Promise<SessionData>) {
|
|
121
|
+
if (locks.has(sessionId)) {
|
|
122
|
+
return locks.get(sessionId)!
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const promise = (async () => {
|
|
126
|
+
try {
|
|
127
|
+
return await fn()
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
locks.delete(sessionId)
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
|
|
134
|
+
locks.set(sessionId, promise)
|
|
135
|
+
return promise
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getForwardRequestHeaders(event: H3Event) {
|
|
139
|
+
const headers = getProxyRequestHeaders(event, { host: false })
|
|
140
|
+
const remoteAddr = getRequestIP(event, { xForwardedFor: true }) || ''
|
|
141
|
+
const xForwardedFor = headers['x-forwarded-for'] || remoteAddr
|
|
142
|
+
|
|
143
|
+
delete headers.cookie
|
|
144
|
+
delete headers.authorization
|
|
145
|
+
if (xForwardedFor) {
|
|
146
|
+
headers['x-forwarded-for'] = xForwardedFor
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return headers
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function pipeResponse(event: H3Event, res: Response) {
|
|
153
|
+
setResponseStatus(event, res.status)
|
|
154
|
+
for (const [key, value] of res.headers) {
|
|
155
|
+
if (key.toLowerCase() === 'set-cookie') {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
setHeader(event, key, value)
|
|
159
|
+
}
|
|
160
|
+
if (res.body == null) {
|
|
161
|
+
return send(event, '')
|
|
162
|
+
}
|
|
163
|
+
return sendStream(event, res.body)
|
|
164
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import type { StringValue } from 'ms'
|
|
3
|
+
import type { SessionData, SessionTombstone } from '#shared/types/auth'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import ms from 'ms'
|
|
6
|
+
|
|
7
|
+
const SESSION_TOMBSTONE_TTL = 30
|
|
8
|
+
const SESSION_ID_LENGTH = 32
|
|
9
|
+
|
|
10
|
+
function createSessionId() {
|
|
11
|
+
return randomBytes(SESSION_ID_LENGTH).toString('hex').slice(0, SESSION_ID_LENGTH)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSessionId(event: H3Event): string | undefined {
|
|
15
|
+
const { session: sessionConfig } = useRuntimeConfig()
|
|
16
|
+
return getCookie(event, sessionConfig.cookieName)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getSessionKey(sessionId: string): string {
|
|
20
|
+
return `session:${sessionId}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setSessionId(id: string, data: Omit<SessionData, 'id'> | SessionData): SessionData {
|
|
24
|
+
return {
|
|
25
|
+
...data,
|
|
26
|
+
id,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getRedisSession(event: H3Event): Promise<SessionData | null> {
|
|
31
|
+
const storage = useStorage<SessionData | SessionTombstone>('redis')
|
|
32
|
+
const sessionId = getSessionId(event)
|
|
33
|
+
if (!sessionId) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = await storage.getItem(getSessionKey(sessionId))
|
|
38
|
+
if (!data) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ('redirectTo' in data) {
|
|
43
|
+
const newData = await storage.getItem(getSessionKey(data.redirectTo))
|
|
44
|
+
if (!newData || 'redirectTo' in newData) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
useRedisSession(event, newData)
|
|
48
|
+
return newData
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return data
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function setRedisSession(event: H3Event, data?: Omit<SessionData, 'id'> | SessionData | null) {
|
|
55
|
+
const { session: sessionConfig } = useRuntimeConfig()
|
|
56
|
+
const cookieName = sessionConfig.cookieName
|
|
57
|
+
const ttl = ms(sessionConfig.maxAge as StringValue) / 1000
|
|
58
|
+
const storage = useStorage<SessionData>('redis')
|
|
59
|
+
|
|
60
|
+
let sessionId = getSessionId(event)
|
|
61
|
+
if (!data) {
|
|
62
|
+
if (sessionId) {
|
|
63
|
+
await storage.removeItem(getSessionKey(sessionId))
|
|
64
|
+
sessionId = undefined
|
|
65
|
+
deleteCookie(event, cookieName)
|
|
66
|
+
}
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const nextSessionId = sessionId || createSessionId()
|
|
71
|
+
const sessionData = setSessionId(nextSessionId, data)
|
|
72
|
+
await storage.setItem(getSessionKey(nextSessionId), sessionData, { ttl })
|
|
73
|
+
setCookie(event, cookieName, nextSessionId, {
|
|
74
|
+
maxAge: ttl,
|
|
75
|
+
domain: sessionConfig.domain || undefined,
|
|
76
|
+
sameSite: 'lax',
|
|
77
|
+
httpOnly: true,
|
|
78
|
+
secure: true,
|
|
79
|
+
priority: 'high',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function rotateRedisSession(event: H3Event, data: Omit<SessionData, 'id'> | SessionData) {
|
|
84
|
+
const { session: sessionConfig } = useRuntimeConfig()
|
|
85
|
+
const ttl = ms(sessionConfig.maxAge as StringValue) / 1000
|
|
86
|
+
const storage = useStorage<SessionData | SessionTombstone>('redis')
|
|
87
|
+
|
|
88
|
+
const oldSessionId = getSessionId(event)
|
|
89
|
+
const sessionId = createSessionId()
|
|
90
|
+
const sessionData = setSessionId(sessionId, data)
|
|
91
|
+
|
|
92
|
+
await storage.setItem(getSessionKey(sessionId), sessionData, { ttl })
|
|
93
|
+
|
|
94
|
+
if (oldSessionId) {
|
|
95
|
+
await storage.setItem(
|
|
96
|
+
getSessionKey(oldSessionId),
|
|
97
|
+
{ redirectTo: sessionId },
|
|
98
|
+
{ ttl: SESSION_TOMBSTONE_TTL },
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
useRedisSession(event, sessionData)
|
|
103
|
+
return sessionData
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function useRedisSession(event: H3Event, session: SessionData) {
|
|
107
|
+
const { session: sessionConfig } = useRuntimeConfig()
|
|
108
|
+
setCookie(event, sessionConfig.cookieName, session.id, {
|
|
109
|
+
maxAge: ms(sessionConfig.maxAge as StringValue) / 1000,
|
|
110
|
+
domain: sessionConfig.domain || undefined,
|
|
111
|
+
sameSite: 'lax',
|
|
112
|
+
httpOnly: true,
|
|
113
|
+
secure: true,
|
|
114
|
+
priority: 'high',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function destroyRedisSession(event: H3Event): Promise<void> {
|
|
119
|
+
return setRedisSession(event, undefined)
|
|
120
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OAuthStateData } from '#shared/types/auth'
|
|
2
|
+
import ms from 'ms'
|
|
3
|
+
|
|
4
|
+
export function getStateKey(state: string): string {
|
|
5
|
+
return `oauth:state:${state}`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function setState(state: string, data: OAuthStateData) {
|
|
9
|
+
const storage = useStorage<OAuthStateData>('redis')
|
|
10
|
+
return storage.setItem(getStateKey(state), data, {
|
|
11
|
+
ttl: ms('5m') / 1000,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getState(state: string) {
|
|
16
|
+
const storage = useStorage<OAuthStateData>('redis')
|
|
17
|
+
return storage.getItem(getStateKey(state))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function removeState(state: string) {
|
|
21
|
+
const storage = useStorage<OAuthStateData>('redis')
|
|
22
|
+
await storage.removeItem(getStateKey(state))
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PublicUser } from '@zvonimirsun/iszy-common'
|
|
2
|
+
|
|
3
|
+
export type { PublicUser, RawPrivilege, RawRole, RegisterUser, ResultDto, UpdateUser } from '@zvonimirsun/iszy-common'
|
|
4
|
+
|
|
5
|
+
export type PublicSimpleUser = Omit<PublicUser, 'status' | 'createBy' | 'updateBy' | 'privileges'>
|
|
6
|
+
|
|
7
|
+
export interface SessionData {
|
|
8
|
+
id: string
|
|
9
|
+
access_token: string
|
|
10
|
+
refresh_token: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionTombstone {
|
|
14
|
+
redirectTo: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OAuthStateData {
|
|
18
|
+
isBind?: boolean
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PublicUser } from '@zvonimirsun/iszy-common'
|
|
2
|
+
import type { PublicSimpleUser } from '#shared/types/auth'
|
|
3
|
+
|
|
4
|
+
export function toPublicSimpleUser(user: PublicUser): PublicSimpleUser {
|
|
5
|
+
const { status, createBy, updateBy, privileges, ...result } = user
|
|
6
|
+
return result
|
|
7
|
+
}
|