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

OIDC for web
This commit is contained in:
2024-10-31 11:10:56 +08:00
committed by GitHub
11 changed files with 358 additions and 91 deletions
+24
View File
@@ -0,0 +1,24 @@
import request from '@/utils/request';
export function loginOptions() {
return request({
url: '/login-options',
method: 'get',
})
}
export function oidcAuth (data) {
return request({
url: '/oidc/auth',
method: 'post',
data,
})
}
export function oidcQuery(params){
return request({
url: '/oidc/auth-query',
method: 'get',
params,
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+55 -13
View File
@@ -1,8 +1,9 @@
import { defineStore, acceptHMRUpdate } from 'pinia' import { defineStore, acceptHMRUpdate } from 'pinia'
import { current, login } from '@/api/user' import { current, login } from '@/api/user'
import { setToken, removeToken } from '@/utils/auth' import { setToken, removeToken, setCode, removeCode } from '@/utils/auth'
import { useRouteStore } from '@/store/router' import { useRouteStore } from '@/store/router'
import { useAppStore } from '@/store/app' import { useAppStore } from '@/store/app'
import { oidcAuth, oidcQuery } from '@/api/login';
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: 'user', id: 'user',
@@ -16,34 +17,40 @@ export const useUserStore = defineStore({
}), }),
actions: { actions: {
logout () { logout() {
removeToken() removeToken()
removeCode()
this.$patch({ this.$patch({
name: '', name: '',
role: {}, role: {},
}) })
}, },
async login (form) { saveUserData(userData) {
// useAppStore().getAppConfig()
setToken(userData.token)
//
localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
this.$patch({
...userData,
})
if (userData.route_names && userData.route_names.length) {
useRouteStore().addRoutes(userData.route_names)
}
},
async login(form) {
const res = await login(form).catch(_ => false) const res = await login(form).catch(_ => false)
if (res) { if (res) {
useAppStore().getAppConfig() useAppStore().getAppConfig()
const userData = res.data const userData = res.data
setToken(userData.token) this.saveUserData(userData)
//
localStorage.setItem('user_info', JSON.stringify({ name: userData.username }))
this.$patch({
...userData,
})
if (userData.route_names && userData.route_names.length) {
useRouteStore().addRoutes(userData.route_names)
}
return userData return userData
} else { } else {
return false return false
} }
}, },
async info () { async info() {
const res = await current().catch(_ => false) const res = await current().catch(_ => false)
if (res) { if (res) {
useAppStore().getAppConfig() useAppStore().getAppConfig()
@@ -57,6 +64,41 @@ export const useUserStore = defineStore({
} }
return false return false
}, },
async oidc(provider, platform, browser) {
// oidc data need to be implement
const data = {
deviceInfo: {
name: navigator.userAgent, // 使用浏览器的 User-Agent 作为设备名
os: platform, // 获取操作系统信息
type: 'webadmin', // any vaule
},
id: `${platform}-${browser}`,
op: provider, // 传入的 provider
uuid: crypto.randomUUID(), // 自动生成 UUID
};
const res = await oidcAuth(data).catch(_ => false)
if (res) {
const { code, url } = res.data
setCode(code)
if (provider == 'webauth') {
window.open(url)
} else {
window.location.href = url
}
}
},
async query(code) {
const params = { "code": code, "uuid": crypto.randomUUID(), "Id": "999" }
const res = await oidcQuery(params).catch(_ => false)
if (res) {
removeCode()
useAppStore().getAppConfig()
const userData = res.data
this.saveUserData(userData)
return userData
}
return false
}
}, },
}) })
+30
View File
@@ -1,4 +1,6 @@
const TokenKey = 'access_token' const TokenKey = 'access_token'
const OidcCode = 'oidc_code'
const OidcCodeExpiry = 'oidc_code_expiry';
export function getToken () { export function getToken () {
return localStorage.getItem(TokenKey) return localStorage.getItem(TokenKey)
@@ -11,3 +13,31 @@ export function setToken (token) {
export function removeToken () { export function removeToken () {
return localStorage.removeItem(TokenKey) return localStorage.removeItem(TokenKey)
} }
// 设置 code,并存储当前时间戳(单位:毫秒)
export function setCode(code) {
const now = Date.now(); // 当前时间戳(毫秒)
const expiry = now + 60 * 1000; // 60 秒后过期
localStorage.setItem(OidcCode, code); // 存储 code
localStorage.setItem(OidcCodeExpiry, expiry); // 存储过期时间戳
}
// 获取 code,如果已过期则删除并返回 null
export function getCode() {
const expiry = localStorage.getItem(OidcCodeExpiry); // 获取过期时间戳
const now = Date.now(); // 当前时间戳
if (expiry && now > parseInt(expiry)) {
// 如果已过期,删除 code 和过期时间
removeCode();
return null;
}
return localStorage.getItem(OidcCode); // 返回 code(如果未过期)
}
// 删除 code 和过期时间
export function removeCode() {
localStorage.removeItem(OidcCode);
localStorage.removeItem(OidcCodeExpiry);
}
+4
View File
@@ -427,5 +427,9 @@
}, },
"LastOnlineIp": { "LastOnlineIp": {
"One": "最后在线IP" "One": "最后在线IP"
},
"or login in with" :
{
"One": "或使用以下登陆"
} }
} }
+6
View File
@@ -55,6 +55,12 @@ service.interceptors.response.use(
response => { response => {
const res = response.data const res = response.data
// for the endpoint /login-options
// I'm not sure if this is a good idea
if (Array.isArray(res)) {
return res;
}
// if the custom code is not 20000, it is judged as an error. // if the custom code is not 20000, it is judged as an error.
if (res.code !== 0) { if (res.code !== 0) {
ElMessage({ ElMessage({
+224 -64
View File
@@ -1,97 +1,257 @@
<template> <template>
<div class="login"> <div class="login-container">
<el-card class="login-card"> <div class="login-card">
<h1>{{ T('Login') }}</h1> <img src="@/assets/logo.png" alt="logo" class="login-logo" />
<el-form label-width="100px">
<el-form-item :label=" T('Username') "> <el-form label-position="top" class="login-form">
<el-input v-model="form.username"></el-input> <el-form-item :label="T('Username')">
<el-input v-model="form.username" class="login-input"></el-input>
</el-form-item> </el-form-item>
<el-form-item :label=" T('Password') ">
<el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password></el-input> <el-form-item :label="T('Password')">
<el-input v-model="form.password" type="password" @keyup.enter.native="login" show-password
class="login-input"></el-input>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button @click="login" type="primary">{{ T('Login') }}</el-button> <el-button @click="login" type="primary" class="login-button">{{ T('Login') }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card>
<div class="divider" v-if="options.length > 0">
<span>{{ T('or login in with') }}</span>
</div>
<div class="oidc-options">
<div v-for="(option, index) in options" :key="index" class="oidc-option">
<el-button @click="handleOIDCLogin(option.name)" class="oidc-btn">
<img :src="getProviderImage(option.name)" alt="provider" class="oidc-icon" />
{{ T(option.name) }}
</el-button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineComponent, reactive } from 'vue' import { reactive, onMounted, ref } from 'vue';
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus';
import { useRoute, useRouter } from 'vue-router' import { T } from '@/utils/i18n';
import { T } from '@/utils/i18n' import { useRoute, useRouter } from 'vue-router';
import { loginOptions, oidcAuth, oidcQuery } from '@/api/login';
import { getCode, removeCode } from '@/utils/auth'
const userStore = useUserStore() const oauthInfo = ref({})
const route = useRoute() const userStore = useUserStore()
const router = useRouter() const route = useRoute()
const router = useRouter()
const options = reactive([]); // 存储 OIDC 登录选项
let platform = window.navigator.platform let platform = window.navigator.platform
if (navigator.platform.indexOf('Mac') === 0) { if (navigator.platform.indexOf('Mac') === 0) {
platform = 'mac' platform = 'mac'
} else if (navigator.platform.indexOf('Win') === 0) { } else if (navigator.platform.indexOf('Win') === 0) {
platform = 'windows' platform = 'windows'
} else if (navigator.platform.indexOf('Linux armv') === 0) { } else if (navigator.platform.indexOf('Linux armv') === 0) {
platform = 'android' platform = 'android'
} else if (navigator.platform.indexOf('Linux') === 0) { } else if (navigator.platform.indexOf('Linux') === 0) {
platform = 'linux' platform = 'linux'
}
const userAgent = navigator.userAgent;
let browser = 'Unknown Browser';
if (/chrome|crios/i.test(userAgent)) browser = 'Chrome';
else if (/firefox|fxios/i.test(userAgent)) browser = 'Firefox';
else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) browser = 'Safari';
else if (/edg/i.test(userAgent)) browser = 'Edge';
const form = reactive({
username: '',
password: '',
platform: platform,
})
const redirect = route.query?.redirect
const login = async () => {
const res = await userStore.login(form)
if (res) {
ElMessage.success(T('LoginSuccess'))
router.push({ path: redirect || '/', replace: true })
} }
}
const form = reactive({ const handleOIDCLogin = (provider) => {
username: '', userStore.oidc(provider, platform, browser)
password: '', };
platform: platform,
}) import googleImage from '@/assets/google.png';
const redirect = route.query?.redirect import githubImage from '@/assets/github.png';
const login = async () => { import oidcImage from '@/assets/oidc.png';
const res = await userStore.login(form) import webauthImage from '@/assets/webauth.png';
import defaultImage from '@/assets/oidc.png';
const providerImageMap = {
google: googleImage,
github: githubImage,
oidc: oidcImage,
webauth: webauthImage,
default: defaultImage,
};
const getProviderImage = (provider) => {
return providerImageMap[provider] || providerImageMap.default;
};
const loadLoginOptions = async () => {
try {
const res = await loginOptions().catch(() => []);
if (!Array.isArray(res) || !res.length) return console.warn('No valid response received');
const jsonPart = res[0].split('/')[1];
if (!jsonPart) return console.error('Invalid input string:', res[0]);
// const ops = JSON.parse(jsonPart).map(option => ({ name: option.name }));
// 不确定怎么处理webauth,不显示
// 解析 JSON,并过滤掉 "webauth" 类型的选项
const ops = JSON.parse(jsonPart)
.filter(option => option.name !== "webauth") // 排除 "webauth" 类型的选项
.map(option => ({ name: option.name })); // 创建新的对象数组
if (!ops.length) return;
options.push(...ops);
} catch (error) {
console.error('Error loading login options:', error.message);
}
};
onMounted(async () => {
const code = getCode();
if (code) {
// 如果code存在,进行query获取user info
const res = await userStore.query(code)
if (res) { if (res) {
// 删除code,确保跳转之前对code进行清楚
removeCode()
ElMessage.success(T('LoginSuccess')) ElMessage.success(T('LoginSuccess'))
router.push({ path: redirect || '/', replace: true }) router.push({ path: redirect || '/', replace: true })
} }
} else {
// 如果code不存在, 现实登陆页面
loadLoginOptions(); // 组件挂载后调用登录选项加载函数
} }
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.login { .login-container {
width: 100vw; display: flex;
justify-content: center;
align-items: center;
height: 100vh; height: 100vh;
background-color: #2d3a4b; background-color: #2d3a4b;
padding-top: 25vh; padding: 20px;
box-sizing: border-box; }
.tips { .login-card {
font-size: 12px; width: 360px;
color: #fff; background-color: #283342;
margin-left: 60px; padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
margin-bottom: 20px;
font-size: 24px;
font-weight: bold;
}
.login-form {
margin-bottom: 20px;
}
.login-input {
width: 100%;
}
.login-button {
width: 100%;
height: 40px;
margin-bottom: 20px;
}
.divider {
display: flex;
align-items: center;
margin: 20px 0;
font-size: 14px;
color: #888;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background-color: #ddd;
} }
.login-card { &::before {
max-width: 500px; margin-right: 10px;
background-color: #283342; }
&::after {
margin-left: 10px;
}
}
.oidc-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.oidc-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 50px;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
color: black;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.oidc-icon {
width: 24px;
height: 24px;
}
.login-logo {
width: 80px;
height: 80px;
margin: 0 auto 20px;
display: block;
}
.el-form-item {
::v-deep(.el-form-item__label) {
color: #fff; color: #fff;
border: none; }
margin: 0 auto;
.el-form-item { .el-input {
::v-deep(.el-input__wrapper) {
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
}
::v-deep(.el-form-item__label) { ::v-deep(input) {
color: #fff; color: #fff;
}
.el-input {
::v-deep(.el-input__wrapper) {
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
}
::v-deep(input) {
color: #fff;
}
}
} }
} }
} }
+14 -13
View File
@@ -11,7 +11,7 @@
<el-card class="list-body" shadow="hover"> <el-card class="list-body" shadow="hover">
<el-table :data="listRes.list" v-loading="listRes.loading" border> <el-table :data="listRes.list" v-loading="listRes.loading" border>
<el-table-column prop="id" label="id" align="center"/> <el-table-column prop="id" label="id" align="center"/>
<el-table-column prop="op" :label="T('Op')" align="center"/> <el-table-column prop="op" :label="T('Type')" align="center"/>
<el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/> <el-table-column prop="auto_register" :label="T('AutoRegister')" align="center"/>
<el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/> <el-table-column prop="created_at" :label="T('CreatedAt')" align="center"/>
<el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/> <el-table-column prop="updated_at" :label="T('UpdatedAt')" align="center"/>
@@ -34,8 +34,18 @@
</el-card> </el-card>
<el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800"> <el-dialog v-model="formVisible" :title="!formData.id?T('Create') :T('Update')" width="800">
<el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px"> <el-form class="dialog-form" ref="form" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="Issuer" prop="issuer"> <el-form-item label="Type" prop="op">
<el-input v-model="formData.issuer" :placeholder="formData.op === 'oidc' ? 'Required when OIDC is selected' : 'Not required unless OIDC is selected'"></el-input> <el-radio-group v-model="formData.op" :disabled="!!formData.id">
<el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="formData.op === 'oidc'" label="Issuer" prop="issuer">
<el-input v-model="formData.issuer" placeholder="Check your IdP docs, without '/.well-known/openid-configuration'"></el-input>
</el-form-item>
<el-form-item v-show="formData.op === 'oidc'" label="Scopes" prop="scopes">
<el-input v-model="formData.scopes" placeholder= "Optional, default is 'openid,profile,email'"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="ClientId" prop="client_id"> <el-form-item label="ClientId" prop="client_id">
<el-input v-model="formData.client_id"></el-input> <el-input v-model="formData.client_id"></el-input>
@@ -46,16 +56,6 @@
<el-form-item label="RedirectUrl" prop="redirect_url"> <el-form-item label="RedirectUrl" prop="redirect_url">
<el-input v-model="formData.redirect_url"></el-input> <el-input v-model="formData.redirect_url"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="Scopes" prop="scopes">
<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>
</el-form-item>
<el-form-item label="op" prop="op">
<el-radio-group v-model="formData.op" :disabled="!!formData.id">
<el-radio v-for="item in ops" :key="item.value" :value="item.value" style="display: block">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="T('AutoRegister')" prop="auto_register"> <el-form-item :label="T('AutoRegister')" prop="auto_register">
<el-switch v-model="formData.auto_register" <el-switch v-model="formData.auto_register"
:active-value="true" :active-value="true"
@@ -146,6 +146,7 @@
client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }], client_secret: [{ required: true, message: T('ParamRequired', { param: 'client_secret' }), trigger: 'blur' }],
redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }], redirect_url: [{ required: true, message: T('ParamRequired', { param: 'redirect_url' }), trigger: 'blur' }],
op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }], op: [{ required: true, message: T('ParamRequired', { param: 'op' }), trigger: 'blur' }],
issuer: [{ required: true, message: T('ParamRequired', { param: 'issuer' }), trigger: 'blur' }],
} }
const toEdit = (row) => { const toEdit = (row) => {
formVisible.value = true formVisible.value = true