实现过程
- 去第三方平台拿到client-id和client-secret,并配置一个能够外网访问回调地址redirect-uri供第三方服务回调
- 搭建后端服务,引入justauth-spring-boot-starter直接在配置文件中定义好第一步的三个参数,并提供获取登录页面的接口和回调接口
- 前端项目中新建一个登录窗口和一个登录中转页面,登录窗口的url从第二步第一个接口获取,中转页面从第二步的第二个接口返回
- 中转页面从url中读取登录成功的用户信息并存放到pinia中,关闭登录窗口并刷新主窗口
1,必要信息获取
第三方平台的client-id和client-secret一般注册开发者平台都能获取。
回调地址需要外网,可以使用花生壳内网穿透随便搞一个,映射到本地的后台服务端口,当后天服务启动成功后确保连接成功
前端代理也可以直接代理到这个域名,前后端完全分离
2,后台服务搭建
2.1 后台如果使用springboot2.x可以从开源框架直接使用:
https://gitee.com/justauth/justauth-spring-boot-starter
只需将上一步获取的三个参数配置到yml文件中
2.2 AuthRequestFactory错误
如果使用的springboot3.x,可能会报错提示:
'com.xkcoding.justauth.AuthRequestFactory' that could not be found.
只需要将AuthRequestFactory、JustAuthProperties、AuthStateRedisCache从源码复制一份到项目中,补全@Configuration、@Component,然后补上一个Bean即可
java
@Bean
public AuthRequestFactory getAuthRequest(JustAuthProperties properties, AuthStateRedisCache authStateCache) {
return new AuthRequestFactory(properties,authStateCache);
}
2.3 redis错误
justauth-spring-boot-starter项目中的redis配置是springboot2.x的配置,
如果是3.x的项目需要将 spring:reids改为 spring:data:reids
2.4 代码案例
java
import com.alibaba.fastjson.JSONObject;
import io.geekidea.springboot.cache.AuthRequestFactory;
import io.geekidea.springboot.service.UserService;
import io.geekidea.springboot.vo.ResponseResult;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.config.AuthConfig;
import io.geekidea.springboot.cache.JustAuthProperties;
import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthToken;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthBaiduRequest;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @Description https://blog.csdn.net/weixin_46684099/article/details/118297276
* @Date 2024/10/23 16:30
* @Author 余乐
**/
@Slf4j
@Controller
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class JustAuthController {
private final UserService userService;
private final AuthRequestFactory factory;
private final JustAuthProperties properties;
@GetMapping
public List<String> list() {
return factory.oauthList();
}
@RequestMapping("/render/{source}")
@ResponseBody
public ResponseResult renderAuth(@PathVariable("source") String source) {
AuthRequest authRequest = null;
//特定平台需要自定义参数的可以单独写AuthConfig
if ("baidu".equals(source)) {
//百度账号默认只有basic,需要网盘权限需要单独定义
List<String> list = new ArrayList<>();
list.add("basic");
list.add("netdisk");
Map<String,AuthConfig> configMap = properties.getType();
AuthConfig authConfig = configMap.get("BAIDU");
authConfig.setScopes(list);
authRequest = new AuthBaiduRequest(authConfig);
} else {
//其他平台账号登录
authRequest = factory.get(source);
}
String state = AuthStateUtils.createState();
String authorizeUrl = authRequest.authorize(state);
return ResponseResult.success(authorizeUrl);
}
/**
* oauth平台中配置的授权回调地址,以本项目为例,在创建github授权应用时的回调地址应为:http://127.0.0.1:8444/oauth/callback/github
*/
@RequestMapping("/callback/{source}")
public void login(@PathVariable("source") String source, AuthCallback callback, HttpServletResponse response2) throws IOException {
log.info("进入callback:{},callback params:{}", source, JSONObject.toJSONString(callback));
AuthRequest authRequest = null;
//特定平台需要自定义参数的可以单独写AuthConfig
if ("baidu".equals(source)) {
//百度账号默认只有basic,需要网盘权限需要单独定义
List<String> list = new ArrayList<>();
list.add("basic");
list.add("netdisk");
Map<String,AuthConfig> configMap = properties.getType();
AuthConfig authConfig = configMap.get("BAIDU");
authConfig.setScopes(list);
authRequest = new AuthBaiduRequest(authConfig);
} else {
//其他平台账号登录
authRequest = factory.get(source);
}
AuthResponse<AuthUser> response = authRequest.login(callback);
String userInfo = JSONObject.toJSONString(response.getData());
log.info("回调用户信息:{}", userInfo);
if (response.ok()) {
userService.save(response.getData());
String userInfoParam = URLEncoder.encode(userInfo, "UTF-8");
//将用户信息放到中转页面的路由参数中,前端从路由参数获取登陆结果
response2.sendRedirect("http://localhost:5173/loginback?data=" + userInfoParam);
}
}
/**
* 注销登录 (前端需要同步清理用户缓存)
*
* @param source
* @param uuid
* @return
* @throws IOException
*/
@RequestMapping("/revoke/{source}/{uuid}")
@ResponseBody
public ResponseResult revokeAuth(@PathVariable("source") String source, @PathVariable("uuid") String uuid) throws IOException {
AuthRequest authRequest = factory.get(source.toLowerCase());
AuthUser user = userService.getByUuid(uuid);
if (null == user) {
return ResponseResult.fail("用户不存在");
}
AuthResponse<AuthToken> response = null;
try {
response = authRequest.revoke(user.getToken());
if (response.ok()) {
userService.remove(user.getUuid());
return ResponseResult.success("用户 [" + user.getUsername() + "] 的 授权状态 已收回!");
}
return ResponseResult.fail("用户 [" + user.getUsername() + "] 的 授权状态 收回失败!" + response.getMsg());
} catch (AuthException e) {
return ResponseResult.fail(e.getErrorMsg());
}
}
/**
* 刷新token
*
* @param source
* @param uuid
* @return
*/
@RequestMapping("/refresh/{source}/{uuid}")
@ResponseBody
public ResponseResult<String> refreshAuth(@PathVariable("source") String source, @PathVariable("uuid") String uuid) {
AuthRequest authRequest = factory.get(source.toLowerCase());
AuthUser user = userService.getByUuid(uuid);
if (null == user) {
return ResponseResult.fail("用户不存在");
}
AuthResponse<AuthToken> response = null;
try {
response = authRequest.refresh(user.getToken());
if (response.ok()) {
user.setToken(response.getData());
userService.save(user);
return ResponseResult.success("用户 [" + user.getUsername() + "] 的 access token 已刷新!新的 accessToken: " + response.getData().getAccessToken());
}
return ResponseResult.fail("用户 [" + user.getUsername() + "] 的 access token 刷新失败!" + response.getMsg());
} catch (AuthException e) {
return ResponseResult.fail(e.getErrorMsg());
}
}
}
3 新建登录窗口和中转页面
3.1 在src/main/index.ts中新增登录窗口
javascript
let loginWindow
//监听打开登录窗口的事件
ipcMain.on('openLoginWin', (event, url) => {
console.log('打开登录窗口', url)
createLoginWindow(url)
})
// 创建登录窗口
function createLoginWindow(url: string) {
loginWindow = new BrowserWindow({
width: 800,
height: 600,
frame: false,
titleBarStyle: 'hidden',
autoHideMenuBar: true,
parent: mainWindow, //父窗口为主窗口
modal: true,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
nodeIntegration: true,
contextIsolation: true
}
})
// 加载登录 URL
loginWindow.loadURL(url)
loginWindow.on('ready-to-show', () => {
loginWindow.show()
})
}
// 关闭登录窗口并刷新主窗口
ipcMain.handle('close-login', () => {
if (loginWindow) {
loginWindow.close()
}
if (mainWindow) {
mainWindow.reload() // 刷新主窗口 }
}
})
3.2 新增中转页面并配置路由
@/views/setting/LoginBack.vue
javascript
<template>
<el-row justify="center">
<cl-col :span="17">
<h2>登陆结果</h2>
<el-icon style="color:#00d28c;font-size: 50px">
<i-mdi-check-circle />
</el-icon>
</cl-col>
</el-row>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { onMounted } from 'vue'
import { useThemeStore } from '@/store/themeStore'
const route = useRoute()
const data = route.query.data
const themeStore = useThemeStore()
//登陆成功自动关闭窗口
onMounted(() => {
console.log("登陆结果",data)
themeStore.setCurrentUser(JSON.parse(data))
setTimeout(() => {
//关闭当前登录回调的窗口,并且刷新主窗口页面
window.electron.ipcRenderer.invoke('close-login')
}, 1000)
})
</script>
3.3 新增路由
javascript
{
path: 'loginback', component: ()=>import("@/views/setting/LoginBack.vue"),
},
这里的路由对应的就是后台/callback 接口重定向的地址
4.管理用户登录信息
后端用户登录信息保存在redis中,如果过期可以使用客户端中缓存的用户uuid刷新token
前端的一般是使用pinia做持久化维护,安装piniad 插件
pinia-plugin-persistedstate
新增用户themeStore.ts
javascript
import { defineStore } from 'pinia';
export const useThemeStore = defineStore('userInfoStore', {
state: () => {
// 从 localStorage 获取主题,如果没有则使用默认值
//const localTheme = localStorage.getItem('localTheme') || 'cool-black';
return {
currentTheme: 'cool-black',
userInfo: {}
};
},
actions: {
setCurrentThemeId(theme: string) {
console.log("修改主题", theme);
this.currentTheme = theme; // 更新当前主题
document.body.setAttribute('data-theme', theme); // 更新 data-theme
},
setCurrentUser(user: any) {
console.log("修改账号", user);
this.userInfo = user; // 更新当前账号
},
},
//开启持久化 = 》 localStorage
persist: {
key: 'userInfoStore',
onstorage: localStorage,
path: ['currentTheme','userInfo']
}
});
5. 运行调试
5.1 在顶部登录页面
javascript
<div v-if="userInfo.avatar">
<el-avatar :src="userInfo.avatar" :size="30"/>
<el-popover :width="300" trigger="click">
<template #reference>
<p>{{userInfo.nickname}}</p>
</template>
<template #default>
<div class="demo-rich-conent" style="display: flex; gap: 16px; flex-direction: column">
<el-avatar
:size="60"
src="https://avatars.githubusercontent.com/u/72015883?v=4"
style="margin-bottom: 8px"
/>
<el-divider />
<h5 @click="logout(userInfo.uuid)">退出登录</h5>
</div>
</template>
</el-popover>
</div>
<div v-else @click.stop="openLoginCard">
<el-avatar :icon="UserFilled" :size="30"/>
<p>未登录</p>
</div>
<script lang="ts" setup>
import {ref} from 'vue'
import { LoginOut } from '@/api/baidu'
import {useThemeStore} from "@/store/themeStore";
import { UserFilled } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getLoginPageUrl } from '../../api/baidu'
const themeStore = useThemeStore();
const router = useRouter()
let searchVal = ref('')
let userInfo=ref({})
if (themeStore.userInfo){
userInfo.value = themeStore.userInfo
}
//打开登录弹窗
function openLoginCard(){
getLoginPageUrl().then(resp => {
console.log("获取登陆地址",resp.data)
window.electron.ipcRenderer.send('openLoginWin',resp.data.data)
});
}
//退出登录
function logout(uuid:string){
LoginOut(uuid).then(resp => {
console.log("注销登录",resp.data)
themeStore.setCurrentUser({})
window.location.reload()
});
}
</script>