Просмотр исходного кода

Merge pull request #3 from IamTaoChen/oidc-for-web

OIDC for web
1 год назад
Родитель
Сommit
a40324a4a9

+ 24 - 0
src/api/login.js

@@ -0,0 +1,24 @@
1
+import request from '@/utils/request';
2
+
3
+export function loginOptions() {
4
+  return request({
5
+    url: '/login-options',
6
+    method: 'get',
7
+  })
8
+}
9
+
10
+export function oidcAuth (data) {
11
+  return request({
12
+    url: '/oidc/auth',
13
+    method: 'post',
14
+    data,
15
+  })
16
+}
17
+
18
+export function oidcQuery(params){
19
+  return request({
20
+    url: '/oidc/auth-query',
21
+    method: 'get',
22
+    params,
23
+  })
24
+}

BIN
src/assets/github.png


BIN
src/assets/google.png


BIN
src/assets/oidc.png


BIN
src/assets/webauth.png


+ 55 - 13
src/store/user.js

@@ -1,8 +1,9 @@
1 1
 import { defineStore, acceptHMRUpdate } from 'pinia'
2 2
 import { current, login } from '@/api/user'
3
-import { setToken, removeToken } from '@/utils/auth'
3
+import { setToken, removeToken, setCode, removeCode } from '@/utils/auth'
4 4
 import { useRouteStore } from '@/store/router'
5 5
 import { useAppStore } from '@/store/app'
6
+import { oidcAuth, oidcQuery } from '@/api/login';
6 7
 
7 8
 export const useUserStore = defineStore({
8 9
   id: 'user',
@@ -16,34 +17,40 @@ export const useUserStore = defineStore({
16 17
   }),
17 18
 
18 19
   actions: {
19
-    logout () {
20
+    logout() {
20 21
       removeToken()
22
+      removeCode()
21 23
       this.$patch({
22 24
         name: '',
23 25
         role: {},
24 26
       })
25 27
     },
26 28
 
27
-    async login (form) {
29
+    saveUserData(userData) {
30
+      // useAppStore().getAppConfig()
31
+      setToken(userData.token)
32
+      //
33
+      localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
34
+      this.$patch({
35
+        ...userData,
36
+      })
37
+      if (userData.route_names && userData.route_names.length) {
38
+        useRouteStore().addRoutes(userData.route_names)
39
+      }
40
+    },
41
+
42
+    async login(form) {
28 43
       const res = await login(form).catch(_ => false)
29 44
       if (res) {
30 45
         useAppStore().getAppConfig()
31 46
         const userData = res.data
32
-        setToken(userData.token)
33
-        //
34
-        localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
35
-        this.$patch({
36
-          ...userData,
37
-        })
38
-        if (userData.route_names && userData.route_names.length) {
39
-          useRouteStore().addRoutes(userData.route_names)
40
-        }
47
+        this.saveUserData(userData)
41 48
         return userData
42 49
       } else {
43 50
         return false
44 51
       }
45 52
     },
46
-    async info () {
53
+    async info() {
47 54
       const res = await current().catch(_ => false)
48 55
       if (res) {
49 56
         useAppStore().getAppConfig()
@@ -57,6 +64,41 @@ export const useUserStore = defineStore({
57 64
       }
58 65
       return false
59 66
     },
67
+    async oidc(provider, platform, browser) {
68
+      // oidc data need to be implement
69
+      const data = {
70
+        deviceInfo: {
71
+          name: navigator.userAgent, // 使用浏览器的 User-Agent 作为设备名
72
+          os: platform, // 获取操作系统信息
73
+          type: 'webadmin', // any vaule
74
+        },
75
+        id: `${platform}-${browser}`,
76
+        op: provider, // 传入的 provider
77
+        uuid: crypto.randomUUID(), // 自动生成 UUID
78
+      };
79
+      const res = await oidcAuth(data).catch(_ => false)
80
+      if (res) {
81
+        const { code, url } = res.data
82
+        setCode(code)
83
+        if (provider == 'webauth') {
84
+          window.open(url)
85
+        } else {
86
+          window.location.href = url
87
+        }
88
+      }
89
+    },
90
+    async query(code) {
91
+      const params = { "code": code, "uuid": crypto.randomUUID(), "Id": "999" }
92
+      const res = await oidcQuery(params).catch(_ => false)
93
+      if (res) {
94
+        removeCode()
95
+        useAppStore().getAppConfig()
96
+        const userData = res.data
97
+        this.saveUserData(userData)
98
+        return userData
99
+      }
100
+      return false
101
+    }
60 102
   },
61 103
 })
62 104
 

+ 30 - 0
src/utils/auth.js

@@ -1,4 +1,6 @@
1 1
 const TokenKey = 'access_token'
2
+const OidcCode = 'oidc_code'
3
+const OidcCodeExpiry = 'oidc_code_expiry';
2 4
 
3 5
 export function getToken () {
4 6
   return localStorage.getItem(TokenKey)
@@ -11,3 +13,31 @@ export function setToken (token) {
11 13
 export function removeToken () {
12 14
   return localStorage.removeItem(TokenKey)
13 15
 }
16
+
17
+// 设置 code,并存储当前时间戳(单位:毫秒)
18
+export function setCode(code) {
19
+  const now = Date.now(); // 当前时间戳(毫秒)
20
+  const expiry = now + 60 * 1000; // 60 秒后过期
21
+
22
+  localStorage.setItem(OidcCode, code); // 存储 code
23
+  localStorage.setItem(OidcCodeExpiry, expiry); // 存储过期时间戳
24
+}
25
+
26
+// 获取 code,如果已过期则删除并返回 null
27
+export function getCode() {
28
+  const expiry = localStorage.getItem(OidcCodeExpiry); // 获取过期时间戳
29
+  const now = Date.now(); // 当前时间戳
30
+
31
+  if (expiry && now > parseInt(expiry)) {
32
+    // 如果已过期,删除 code 和过期时间
33
+    removeCode();
34
+    return null;
35
+  }
36
+  return localStorage.getItem(OidcCode); // 返回 code(如果未过期)
37
+}
38
+
39
+// 删除 code 和过期时间
40
+export function removeCode() {
41
+  localStorage.removeItem(OidcCode);
42
+  localStorage.removeItem(OidcCodeExpiry);
43
+}

+ 4 - 0
src/utils/i18n/zh_CN.json

@@ -427,5 +427,9 @@
427 427
   },
428 428
   "LastOnlineIp": {
429 429
     "One": "最后在线IP"
430
+  },
431
+  "or login in with" :
432
+  {
433
+    "One": "或使用以下登陆"
430 434
   }
431 435
 }

+ 6 - 0
src/utils/request.js

@@ -55,6 +55,12 @@ service.interceptors.response.use(
55 55
   response => {
56 56
     const res = response.data
57 57
 
58
+    // for the endpoint /login-options
59
+    // I'm not sure if this is a good idea
60
+    if (Array.isArray(res)) {
61
+      return res;
62
+    }
63
+
58 64
     // if the custom code is not 20000, it is judged as an error.
59 65
     if (res.code !== 0) {
60 66
       ElMessage({

+ 225 - 65
src/views/login/login.vue

@@ -1,98 +1,258 @@
1 1
 <template>
2
-  <div class="login">
3
-    <el-card class="login-card">
4
-      <h1>{{ T('Login') }}</h1>
5
-      <el-form label-width="100px">
6
-        <el-form-item :label=" T('Username') ">
7
-          <el-input v-model="form.username"></el-input>
2
+  <div class="login-container">
3
+    <div class="login-card">
4
+      <img src="@/assets/logo.png" alt="logo" class="login-logo" />
5
+
6
+      <el-form label-position="top" class="login-form">
7
+        <el-form-item :label="T('Username')">
8
+          <el-input v-model="form.username" class="login-input"></el-input>
8 9
         </el-form-item>
9
-        <el-form-item :label=" T('Password') ">
10
-          <el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password></el-input>
10
+
11
+        <el-form-item :label="T('Password')">
12
+          <el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password
13
+            class="login-input"></el-input>
11 14
         </el-form-item>
15
+
12 16
         <el-form-item>
13
-          <el-button @click="login" type="primary">{{ T('Login') }}</el-button>
17
+          <el-button @click="login" type="primary" class="login-button">{{ T('Login') }}</el-button>
14 18
         </el-form-item>
15 19
       </el-form>
16
-    </el-card>
20
+
21
+      <div class="divider" v-if="options.length > 0">
22
+        <span>{{ T('or login in with') }}</span>
23
+      </div>
24
+
25
+      <div class="oidc-options">
26
+        <div v-for="(option, index) in options" :key="index" class="oidc-option">
27
+          <el-button @click="handleOIDCLogin(option.name)" class="oidc-btn">
28
+            <img :src="getProviderImage(option.name)" alt="provider" class="oidc-icon" />
29
+            {{ T(option.name) }}
30
+          </el-button>
31
+        </div>
32
+      </div>
33
+    </div>
17 34
   </div>
18 35
 </template>
19 36
 
20 37
 <script setup>
21
-  import { defineComponent, reactive } from 'vue'
22
-  import { useUserStore } from '@/store/user'
23
-  import { ElMessage } from 'element-plus'
24
-  import { useRoute, useRouter } from 'vue-router'
25
-  import { T } from '@/utils/i18n'
26
-
27
-  const userStore = useUserStore()
28
-  const route = useRoute()
29
-  const router = useRouter()
30
-
31
-  let platform = window.navigator.platform
32
-  if (navigator.platform.indexOf('Mac') === 0) {
33
-    platform = 'mac'
34
-  } else if (navigator.platform.indexOf('Win') === 0) {
35
-    platform = 'windows'
36
-  } else if (navigator.platform.indexOf('Linux armv') === 0) {
37
-    platform = 'android'
38
-  } else if (navigator.platform.indexOf('Linux') === 0) {
39
-    platform = 'linux'
38
+import { reactive, onMounted, ref } from 'vue';
39
+import { useUserStore } from '@/store/user'
40
+import { ElMessage } from 'element-plus';
41
+import { T } from '@/utils/i18n';
42
+import { useRoute, useRouter } from 'vue-router';
43
+import { loginOptions, oidcAuth, oidcQuery } from '@/api/login';
44
+import { getCode, removeCode } from '@/utils/auth'
45
+
46
+const oauthInfo = ref({})
47
+const userStore = useUserStore()
48
+const route = useRoute()
49
+const router = useRouter()
50
+const options = reactive([]); // 存储 OIDC 登录选项
51
+
52
+let platform = window.navigator.platform
53
+if (navigator.platform.indexOf('Mac') === 0) {
54
+  platform = 'mac'
55
+} else if (navigator.platform.indexOf('Win') === 0) {
56
+  platform = 'windows'
57
+} else if (navigator.platform.indexOf('Linux armv') === 0) {
58
+  platform = 'android'
59
+} else if (navigator.platform.indexOf('Linux') === 0) {
60
+  platform = 'linux'
61
+}
62
+const userAgent = navigator.userAgent;
63
+let browser = 'Unknown Browser';
64
+if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
65
+else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
66
+else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) browser = 'Safari';
67
+else if (/edg/i.test(userAgent)) browser = 'Edge';
68
+
69
+const form = reactive({
70
+  username: '',
71
+  password: '',
72
+  platform: platform,
73
+})
74
+
75
+const redirect = route.query?.redirect
76
+const login = async () => {
77
+  const res = await userStore.login(form)
78
+  if (res) {
79
+    ElMessage.success(T('LoginSuccess'))
80
+    router.push({ path: redirect || '/', replace: true })
81
+  }
82
+}
83
+
84
+const handleOIDCLogin = (provider) => {
85
+  userStore.oidc(provider, platform, browser)
86
+};
87
+
88
+import googleImage from '@/assets/google.png';
89
+import githubImage from '@/assets/github.png';
90
+import oidcImage from '@/assets/oidc.png';
91
+import webauthImage from '@/assets/webauth.png';
92
+import defaultImage from '@/assets/oidc.png';
93
+
94
+const providerImageMap = {
95
+  google: googleImage,
96
+  github: githubImage,
97
+  oidc: oidcImage,
98
+  webauth: webauthImage,
99
+  default: defaultImage,
100
+};
101
+
102
+const getProviderImage = (provider) => {
103
+  return providerImageMap[provider] || providerImageMap.default;
104
+};
105
+
106
+const loadLoginOptions = async () => {
107
+  try {
108
+    const res = await loginOptions().catch(() => []);
109
+    if (!Array.isArray(res) || !res.length) return console.warn('No valid response received');
110
+
111
+    const jsonPart = res[0].split('/')[1];
112
+    if (!jsonPart) return console.error('Invalid input string:', res[0]);
113
+
114
+    // const ops = JSON.parse(jsonPart).map(option => ({ name: option.name }));
115
+    // 不确定怎么处理webauth,不显示
116
+    // 解析 JSON,并过滤掉 "webauth" 类型的选项
117
+    const ops = JSON.parse(jsonPart)
118
+      .filter(option => option.name !== "webauth") // 排除 "webauth" 类型的选项
119
+      .map(option => ({ name: option.name })); // 创建新的对象数组
120
+    if (!ops.length) return;
121
+
122
+    options.push(...ops);
123
+  } catch (error) {
124
+    console.error('Error loading login options:', error.message);
40 125
   }
126
+};
41 127
 
42
-  const form = reactive({
43
-    username: '',
44
-    password: '',
45
-    platform: platform,
46
-  })
47
-  const redirect = route.query?.redirect
48
-  const login = async () => {
49
-    const res = await userStore.login(form)
128
+onMounted(async () => {
129
+  const code = getCode();
130
+  if (code) {
131
+    // 如果code存在,进行query获取user info
132
+    const res = await userStore.query(code)
50 133
     if (res) {
134
+      // 删除code,确保跳转之前对code进行清楚
135
+      removeCode()
51 136
       ElMessage.success(T('LoginSuccess'))
52 137
       router.push({ path: redirect || '/', replace: true })
53 138
     }
139
+  } else {
140
+    // 如果code不存在, 现实登陆页面
141
+    loadLoginOptions(); // 组件挂载后调用登录选项加载函数
54 142
   }
143
+});
55 144
 </script>
56 145
 
57 146
 <style scoped lang="scss">
58
-.login {
59
-  width: 100vw;
147
+.login-container {
148
+  display: flex;
149
+  justify-content: center;
150
+  align-items: center;
60 151
   height: 100vh;
61 152
   background-color: #2d3a4b;
62
-  padding-top: 25vh;
63
-  box-sizing: border-box;
153
+  padding: 20px;
154
+}
64 155
 
65
-  .tips {
66
-    font-size: 12px;
67
-    color: #fff;
68
-    margin-left: 60px;
156
+.login-card {
157
+  width: 360px;
158
+  background-color: #283342;
159
+  padding: 40px;
160
+  border-radius: 8px;
161
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
162
+  text-align: center;
163
+}
164
+
165
+h1 {
166
+  margin-bottom: 20px;
167
+  font-size: 24px;
168
+  font-weight: bold;
169
+}
170
+
171
+.login-form {
172
+  margin-bottom: 20px;
173
+}
174
+
175
+.login-input {
176
+  width: 100%;
177
+}
178
+
179
+.login-button {
180
+  width: 100%;
181
+  height: 40px;
182
+  margin-bottom: 20px;
183
+}
184
+
185
+.divider {
186
+  display: flex;
187
+  align-items: center;
188
+  margin: 20px 0;
189
+  font-size: 14px;
190
+  color: #888;
191
+
192
+  &::before,
193
+  &::after {
194
+    content: '';
195
+    flex: 1;
196
+    height: 1px;
197
+    background-color: #ddd;
69 198
   }
70 199
 
71
-  .login-card {
72
-    max-width: 500px;
73
-    background-color: #283342;
74
-    color: #fff;
75
-    border: none;
76
-    margin: 0 auto;
200
+  &::before {
201
+    margin-right: 10px;
202
+  }
77 203
 
78
-    .el-form-item {
204
+  &::after {
205
+    margin-left: 10px;
206
+  }
207
+}
208
+
209
+.oidc-options {
210
+  display: flex;
211
+  flex-direction: column;
212
+  gap: 10px;
213
+}
79 214
 
80
-      ::v-deep(.el-form-item__label) {
81
-        color: #fff;
82
-      }
215
+.oidc-btn {
216
+  display: flex;
217
+  align-items: center;
218
+  justify-content: center;
219
+  gap: 10px;
220
+  width: 100%;
221
+  height: 50px;
222
+  background-color: white;
223
+  border: 1px solid #ddd;
224
+  border-radius: 4px;
225
+  color: black;
226
+  font-size: 14px;
227
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
228
+}
83 229
 
84
-      .el-input {
230
+.oidc-icon {
231
+  width: 24px;
232
+  height: 24px;
233
+}
85 234
 
86
-        ::v-deep(.el-input__wrapper) {
87
-          border: 1px solid rgba(255, 255, 255, 0.1);
88
-          background: transparent;
89
-        }
235
+.login-logo {
236
+  width: 80px;
237
+  height: 80px;
238
+  margin: 0 auto 20px;
239
+  display: block;
240
+}
241
+
242
+.el-form-item {
243
+  ::v-deep(.el-form-item__label) {
244
+    color: #fff;
245
+  }
246
+
247
+  .el-input {
248
+    ::v-deep(.el-input__wrapper) {
249
+      border: 1px solid rgba(255, 255, 255, 0.1);
250
+      background: transparent;
251
+    }
90 252
 
91
-        ::v-deep(input) {
92
-          color: #fff;
93
-        }
94
-      }
253
+    ::v-deep(input) {
254
+      color: #fff;
95 255
     }
96 256
   }
97 257
 }
98
-</style>
258
+</style>

+ 14 - 13
src/views/oauth/index.vue

@@ -11,7 +11,7 @@
11 11
     <el-card class="list-body" shadow="hover">
12 12
       <el-table :data="listRes.list" v-loading="listRes.loading" border>
13 13
         <el-table-column prop="id" label="id" align="center"/>
14
-        <el-table-column prop="op" :label="T('Op')" align="center"/>
14
+        <el-table-column prop="op" :label="T('Type')" align="center"/>
15 15
         <el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/>
16 16
         <el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/>
17 17
         <el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/>
@@ -34,8 +34,18 @@
34 34
     </el-card>
35 35
     <el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800">
36 36
       <el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px">
37
-        <el-form-item label="Issuer" prop="issuer">
38
-          <el-input v-model="formData.issuer" :placeholder="formData.op === 'oidc' ? 'Required when OIDC is selected' : 'Not required unless OIDC is selected'"></el-input>
37
+        <el-form-item label="Type" prop="op">
38
+          <el-radio-group v-model="formData.op" :disabled="!!formData.id">
39
+            <el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
40
+              {{ item.label }}
41
+            </el-radio>
42
+          </el-radio-group>
43
+        </el-form-item>
44
+        <el-form-item v-if="formData.op === 'oidc'" label="Issuer" prop="issuer">
45
+          <el-input v-model="formData.issuer" placeholder="Check your IdP docs, without '/.well-known/openid-configuration'"></el-input>
46
+        </el-form-item>
47
+        <el-form-item v-show="formData.op === 'oidc'" label="Scopes" prop="scopes">
48
+          <el-input v-model="formData.scopes" placeholder= "Optional, default is 'openid,profile,email'"></el-input>
39 49
         </el-form-item>
40 50
         <el-form-item label="ClientId" prop="client_id">
41 51
           <el-input v-model="formData.client_id"></el-input>
@@ -46,16 +56,6 @@
46 56
         <el-form-item label="RedirectUrl" prop="redirect_url">
47 57
           <el-input v-model="formData.redirect_url"></el-input>
48 58
         </el-form-item>
49
-        <el-form-item label="Scopes" prop="scopes">
50
-          <el-input v-model="formData.scopes" :placeholder="formData.op === 'oidc' ? 'Optional when OIDC is selected, default is openid,profile,email' : 'Not required unless OIDC is selected'"></el-input>
51
-        </el-form-item>
52
-        <el-form-item label="op" prop="op">
53
-          <el-radio-group v-model="formData.op" :disabled="!!formData.id">
54
-            <el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
55
-              {{ item.label }}
56
-            </el-radio>
57
-          </el-radio-group>
58
-        </el-form-item>
59 59
         <el-form-item :label="T('AutoRegister')" prop="auto_register">
60 60
           <el-switch v-model="formData.auto_register"
61 61
                      :active-value="true"
@@ -146,6 +146,7 @@
146 146
     client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }],
147 147
     redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }],
148 148
     op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }],
149
+    issuer: [{ required: true, message: T('ParamRequired', { param: 'issuer' }), trigger: 'blur' }],
149 150
   }
150 151
   const toEdit = (row) => {
151 152
     formVisible.value = true