搭建好keycloak,我用的是Windows版本的,本地启动之后开始
一、开始配置keycloak
1.创建Realm,鄙人取名为SpringBoot,可以自定义
然后选中这个realm,接下来的操作都是在realm名称为SpringBoot下操作的
2.创建两个客户端,一个给SpringBoot项目用,一个给vue项目用
spring-boot-demo
打开spring-boot-demo,进行配置
Access Type设置为bearer-only
Credentials中的Secret值要记下来,待会配置SpringBoot项目时要用到
5612ea78-ab94-4b2e-844c-9b43ec84728a
再创建一个vue-demo,给前端项目用
这里和上面的spring-boot-demo配置有些不同。要配置重定向的路径,用于重定向,当访问下图中的路径时,keycloak会重定向到keycloak的登录页面。
本人的项目是前后端分离的,下面的路径是前端的ip和端口。也可以通过修改hosts文件 通过域名进行访问
3.创建用户和角色,并绑定用户和角色
给角色起个名称 ROLE_ADMIN,保存
创建用户,SpringBoot配置项中会用到
给用户起个名字admin,之后保存
然后给admin用户分配角色用户,刚才创建了admin,假如这里不显示就点击View All users按钮查询一下
配置用户的登录密码,记得Temporary 要设置为Off
给用户admin分配角色
分配之后如下图
到此keycloak配置完成了
二、SpringBoot项目集成keycloak
1.添加maven依赖
<dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> <version>10.0.0</version> </dependency>
2.配置文件application.properties添加keycloak
keycloak.realm就是第一步中的第1小步
keycloak.auth-server-url就是keycloak的路径,我是安装在本机的,所以是localhost,端口号是默认的8080
keycloak.ssl-required external这里是默认的,不用修改
keycloak.resource 这个是我们第一步中给SpringBot创建的client,并且我们还记录了他的secret,下面就用到了
keycloak.credentials.secret 就是我们记录下来的5612ea78-ab94-4b2e-844c-9b43ec84728a
keycloak.use-resource-role-mappings 默认设置为true
keycloak.realm = SpringBoot keycloak.auth-server-url = http://127.0.0.1:8080/auth keycloak.ssl-required = external keycloak.resource = spring-boot-demo keycloak.credentials.secret = 5612ea78-ab94-4b2e-844c-9b43ec84728a keycloak.use-resource-role-mappings = true
我们用的是application.properties,当然也可以用application.yml
security-constraints 配置的是角色和用户的信息
keycloak:
realm: SpringBoot
auth-server-url: http://127.0.0.1:8080/auth
resource: spring-boot-demo
ssl-required: external
credentials:
secret: 5612ea78-ab94-4b2e-844c-9b43ec84728a
bearer-only: true
use-resource-role-mappings: false
cors: true
security-constraints:
- authRoles:
- ROLE_ADMIN
securityCollections:
- name: admin
patterns:
- /admin
到此springboot配置完成
3.若依有自己的校验,假如说要求使用keyclock的token进行校验的话,本人进行了如下修改
登录login方法
- 将验证码验证的部分去掉了
- 将keyclock的token传过来,具体见下面vue中是怎么传输的(集成vue的第2步中)
- 修改了createToken方法
controller
/** * 登录方法 * * @param loginBody 登录信息 * @return 结果 */ @PostMapping("/login") public AjaxResult login(@RequestBody LoginBody loginBody,HttpServletResponse httpServletResponse) throws IOException { AjaxResult ajax = AjaxResult.success(); // TODO 查询keyclock的用户是否存在与数据库,不存在则添加 SysUser sysUser = sysUserService.selectUserByUserName(loginBody.getUsername()); // 生成令牌(整合keyclock后,这个令牌就不用了,但是方法里面面的内容还有用) String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), loginBody.getUuid()); // 这里直接返回keycloak生成的token ajax.put(Constants.TOKEN, loginBody.getCode()); return ajax; }
service
/** * 登录验证 * * @param username 用户名 * @param password 密码 * @param code 验证码(整合keyclock后,code不再是验证码,而是keyclock的token) * @param uuid 验证码唯一标识 * @return 结果 */ public String login(String username, String password, String code, String uuid) { //验证码验证 verifyLoginCode(username,code,uuid); // 用户验证 Authentication authentication = null; try { // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername authentication = authenticationManager .authenticate(new UsernamePasswordAuthenticationToken(username, password)); } catch (Exception e) { if (e instanceof BadCredentialsException) { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match"))); throw new UserPasswordNotMatchException(); } else { AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage())); throw new CustomException(e.getMessage()); } } AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success"))); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 生成token return tokenService.createKeycToken(loginUser,code); }
createKeycToken方法
/** * 根据keyclock的token,生成与用户关联的token * 用keyclock代替了 String token = IdUtils.fastUUID(); * @param loginUser 用户信息 * @param token keyclock生成的token * @return */ public String createKeycToken(LoginUser loginUser,String token) { // TODO if(StringUtils.isNotEmpty(token)){ // String token = code.substring(code.length()-48,code.length()); loginUser.setToken(token); setUserAgent(loginUser); refreshToken(loginUser); Map<String, Object> claims2 = new HashMap<>(); claims2.put(Constants.LOGIN_USER_KEY, token); createToken(claims2); return createToken(claims2); } return null; }
获取用户信息的方法getInfo
- controller中没有修改
- 修改了service中的getLoginUser方法
service中的getLoginUser方法修改该如下
public LoginUser getLoginUser(HttpServletRequest request) { // TODO 拿到keycloak生成的token String token = getToken(request); if(StringUtils.isNotEmpty(token)){ // 这里截取一下,代替 String token = IdUtils.fastUUID(); // 为什么在这里需要创建一下token呢?因为我们用createKeycToken代替了createToken // String tok2 = token.substring(token.length()-48,token.length()); String tok2 = token; Map<String, Object> claims2 = new HashMap<>(); claims2.put(Constants.LOGIN_USER_KEY, tok2); String tok3 = createToken(claims2); // 通过上边的createToken,找到相应的user信息 Claims claims3 = parseToken(tok3); // 解析对应的权限以及用户信息 String uuid2 = (String) claims3.get(Constants.LOGIN_USER_KEY); String userKey2 = getTokenKey(uuid2); LoginUser user2 = redisCache.getCacheObject(userKey2); return user2; } // 获取请求携带的令牌 // String token = getToken(request); // if (StringUtils.isNotEmpty(token)) // { // Claims claims = parseToken(token); // // 解析对应的权限以及用户信息 // String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); // String userKey = getTokenKey(uuid); // LoginUser user = redisCache.getCacheObject(userKey); // return user; // } return null; }
到此SpringBoot配置keycloak结束
三、vue集成keycloak
集成keycloak后,在启动的时候会有一些错误,因为需要下载keycloak相关的东西,根据报错下载就好了。
我的思想如下:
当访问登录页的时候,通过keycloak进行保护项目(也称之为单点登录),会跳转到keycloak的登录页面,所以在role中配置了要重定向的路径。
当我们重定向到keycloak登录页后,进行登录keycloak操作,用刚才配置好的admin和设置好的密码登录,登陆之后怎么办呢?
要直接跳转到我们的vue登陆之后的主页面,但是现在我们没有登录vue,怎么办呢?
在vue页面设置自动登录,这里就涉及到账号密码的问题了,要在页面上默认写死掉,然后去掉前端验证码验证的环节(后台的验证码验证也要去掉)
这样就可以在登录keycloak之后,自动跳转到项目的首页了。
另一个问题又来了,在登录keycloak后会跳转到vue的登录页,然后再自动登录,这样就会有两个登录页面,解决办法就是给vue的登录页面进行隐藏
具体操作如下
1.在main.js中引入相关配置
// keycloak import Keycloak from '@dsb-norge/vue-keycloak-js'; import axios from "axios";
- url 使我们的keycloak路径realm 就是我们的SpringBoot
- clientId就是我们的前端client名称 vue-demo
- requestAuth 鄙人也不知道是干嘛用的
- 其中window.localStorage.setItem 的作用是在登录keyclock后,将keyclock的登录用户名和token放到localStorage中,vue的登录页会用到
// 全局配置keycloak
Vue.use(Keycloak , {
init: {
onLoad: 'login-required'
},
config: {
url: 'http://localhost:8080/auth',
// url: 'http://192.168.186.129:8080/auth',
// url: 'http://60.208.139.42:55943/auth',
realm: 'SpringBoot',
clientId: 'vue-cli'
},
onReady: (keycloak) => {
keycloak.loadUserProfile().success((data) => {
// requestAuth;
window.localStorage.setItem("username",JSON.stringify(data.username));
window.localStorage.setItem("token",JSON.stringify(keycloak.token));
});
}
});
我们通过axios,首先访问vue的主页面,访问的时候会被keycloak拦截,然后我们登录keycloak,登录后会自动跳转到vue的主页面。
假如说keycloak已经登录了,他就会直接跳转到vue主页面
localhost:10002/index就是vue主页面的路径
//访问后端接口 axios.get('localhost:10002/index',{ }).then(respose=>{ // 访问成功则放行 next(); }).catch(error=>{ //访问不成功 则跳转keyclock登陆 let keycloak = null; keycloak = Keycloak({ url: 'http://127.0.0.1:8080/auth/' , // url: 'http://127.0.0.1:8080/auth/realms/SpringBoot/protocol/openid-connect/auth?client_id=vue-demo&redirect_uri=http%3A%2F%2Flocalhost%3A8083%2Flogin%3Fredirect%3D%252Findex&state=579c8d30-aafa-42ad-8382-eda8ab5c7bd1&response_mode=fragment&response_type=code&scope=openid&nonce=59a5569f-3185-464f-99d9-979e62462bcc' , // url: 'http://192.168.186.129:8080/auth/' , // url: 'http://http://60.208.139.42:55943/auth/' , realm: 'SpringBoot', clientId: 'vue-demo', }); // keycloak.init({onLoad: 'login-required'}).success( // function (authenticated) { // // if (!authenticated) { // alert('not authenticated') // } else { // keycloak.loadUserProfile().success(data => { // // keycloak.logout(); // }) // } // // console.info(keycloak) // }).error(function () { // alert('failed to initialize'); // }); })
上面是vue集成keycloak相关配置,鄙人整体的main.js如下
import Vue from 'vue'
import Cookies from 'js-cookie'
import Element from 'element-ui'
import './assets/styles/element-variables.scss'
import '@/assets/styles/index.scss' // global css
import '@/assets/styles/ruoyi.scss' // ruoyi css
import App from './App'
import store from './store'
import router from './router'
import permission from './directive/permission'
import './assets/icons' // icon
import './permission' // permission control
import { getDicts } from "@/api/system/dict/data";
import { getConfigKey } from "@/api/system/config";
import { parseTime, resetForm, addDateRange, selectDictLabel, selectDictLabels, download, handleTree } from "@/utils/ruoyi";
import Pagination from "@/components/Pagination";
// 自定义表格工具扩展
import RightToolbar from "@/components/RightToolbar"
// keycloak
import Keycloak from '@dsb-norge/vue-keycloak-js';
import axios from "axios";
import {login,login2} from '@/api/login'
// 全局方法挂载
Vue.prototype.getDicts = getDicts
Vue.prototype.getConfigKey = getConfigKey
Vue.prototype.parseTime = parseTime
Vue.prototype.resetForm = resetForm
Vue.prototype.addDateRange = addDateRange
Vue.prototype.selectDictLabel = selectDictLabel
Vue.prototype.selectDictLabels = selectDictLabels
Vue.prototype.download = download
Vue.prototype.handleTree = handleTree
Vue.prototype.msgSuccess = function (msg) {
this.$message({ showClose: true, message: msg, type: "success" });
}
Vue.prototype.msgError = function (msg) {
this.$message({ showClose: true, message: msg, type: "error" });
}
Vue.prototype.msgInfo = function (msg) {
this.$message.info(msg);
}
// 全局组件挂载
Vue.component('Pagination', Pagination)
Vue.component('RightToolbar', RightToolbar)
Vue.use(permission)
/**
* If you don't want to use mock-server
* you want to use MockJs for mock api
* you can execute: mockXHR()
*
* Currently MockJs will be used in the production environment,
* please remove it before going online! ! !
*/
Vue.use(Element, {
size: Cookies.get('size') || 'medium' // set element-ui default size
})
Vue.config.productionTip = false
new Vue({
el: '#app',
router,
store,
render: h => h(App)
})
Vue.use(Keycloak , {
init: {
onLoad: 'login-required'
},
config: {
url: 'http://localhost:8080/auth',
realm: 'SpringBoot',
clientId: 'vue-cli'
},
onReady: (keycloak) => {
keycloak.loadUserProfile().success((data) => {
// requestAuth;
window.localStorage.setItem("username",JSON.stringify(data.username));
window.localStorage.setItem("token",JSON.stringify(keycloak.token));
});
}
});
//访问后端接口
axios.get('http://localhost:8083/login',{
}).then(respose=>{
// 访问成功则放行
next();
}).catch(error=>{
//访问不成功 则跳转keyclock登陆
let keycloak = null;
keycloak = Keycloak({
url: 'http://127.0.0.1:8080/auth/' ,
realm: 'SpringBoot',
clientId: 'vue-cli',
});
})
2.修改登录页面,修改为自动登录,并且隐藏登录页
这里根据自己的项目进行修改
我的login.vue如下,修改的地方有四个
1. style="display:none" 设置为隐藏
2. 默认密码,并且将相关的验证码模块删除掉(后台也要删除),用户名取得keyclock的用户名,之前我们放到了localStorage中
loginForm: {
username: "",
password: "admin123",
rememberMe: false,
code: "",
uuid: ""
}
3. 获取Cookie信息,以及localStorage中的keyclock信息,这里将token放到了code中传到了后台
mounted(){
this.loginForm.username = JSON.parse(window.localStorage.getItem("username"));
this.loginForm.code = JSON.parse(window.localStorage.getItem("token"));
// this.getCode();
this.getCookie();
},
4.自动登录,写在了getCookie方法中。自动登录时我们用了定时,为什么用写在了代码中
this.handleLogin();
<template>
<div class="login" style="display:none">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">AOH后台管理系统</h3>
<el-form-item prop="username">
<el-input v-model="loginForm.username" type="text" auto-complete="off" placeholder="账号">
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
auto-complete="off"
placeholder="密码"
@keyup.enter.native="handleLogin"
>
<svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input v-model="loginForm.code" type="text" auto-complete="off" placeholder="code">
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
<el-form-item style="width:100%;">
<el-button
:loading="loading"
size="medium"
type="primary"
style="width:100%;"
@click.native.prevent="handleLogin"
>
<span v-if="!loading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
</el-form-item>
</el-form>
<!-- 底部 -->
<div class="el-login-footer">
<span>Copyright © 2020-2021 NSCCJN All Rights Reserved.</span>
</div>
</div>
</template>
<script>
import { getCodeImg } from "@/api/login";
import Cookies from "js-cookie";
import { encrypt, decrypt } from '@/utils/jsencrypt'
export default {
name: "Login",
data() {
return {
codeUrl: "",
cookiePassword: "",
loginForm: {
username: '',
password: "admin123",
rememberMe: false,
code: "",
uuid: ""
},
loginRules: {
//username: [
//{ required: true, trigger: "blur", message: "用户名不能为空" }
//],
password: [
{ required: true, trigger: "blur", message: "密码不能为空" }
],
// code: [{ required: true, trigger: "change", message: "验证码不能为空" }]
},
loading: false,
redirect: undefined,
};
},
watch: {
$route: {
handler: function(route) {
this.redirect = route.query && route.query.redirect;
},
immediate: true
}
},
created() {
// this.loginForm.username = JSON.parse(window.sessionStorage.getItem("username"));
},
mounted(){
this.loginForm.username = JSON.parse(window.localStorage.getItem("username"));
this.loginForm.code = JSON.parse(window.localStorage.getItem("token"));
// this.getCode();
this.getCookie();
},
methods: {
getCode() {
getCodeImg().then(res => {
this.codeUrl = "data:image/gif;base64," + res.img;
this.loginForm.uuid = res.uuid;
});
},
getCookie() {
const username = Cookies.get("username");
const password = Cookies.get("password");
const code = JSON.parse(window.localStorage.getItem("token"));
const rememberMe = Cookies.get('rememberMe')
this.loginForm = {
username: username === undefined ? this.loginForm.username : username,
password: password === undefined ? this.loginForm.password : decrypt(password),
code:code === undefined ? this.loginForm.code : code,
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
};
if(this.loginForm.username == '' || this.loginForm.username == null){
/**
* 这里为什么要加一个定时任务呢?原因如下5点
* 1.系统会首先访问login页面,此时keycloak还未登陆,拿不到keycloak的用户名,然后跳转单keycloak登录页
* 2.keycloak登陆之后,将keycloak的用户名和token放到了localStorage中,之后跳转到login页面
* 3.跳转过来之后,localStorage中有信息了,但是此时的页面是还处于1的状态,虽说有值但是取不到,需要刷新一下页面
* 4.这就用到了定时,刷新login页面后取到username,之后自动登录,登陆之后login页面就用不到了,所以定时任务对系统的使用没有影响
* 5.假如将定时任务加到keycloak中,那就会出现一直刷新
*/
setTimeout(()=>{
window.location.href='/login';
// location.reload();
},100)
}else{
this.handleLogin();
}
},
handleLogin() {
//获取用户信息可以使用
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
if (this.loginForm.rememberMe) {
Cookies.set("username", this.loginForm.username, { expires: 30 });
Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
} else {
Cookies.remove("username");
Cookies.remove("password");
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
}).catch(() => {
this.loading = false;
this.getCode();
});
}
});
}
}
};
</script>
<style rel="stylesheet/scss" lang="scss">
.login {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background-image: url("../assets/images/login-background.jpg");
background-size: cover;
}
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
}
.login-form {
border-radius: 6px;
background: #ffffff;
width: 400px;
padding: 25px 25px 5px 25px;
.el-input {
height: 38px;
input {
height: 38px;
}
}
.input-icon {
height: 39px;
width: 14px;
margin-left: 2px;
}
}
.login-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
}
.login-code {
width: 33%;
height: 38px;
float: right;
img {
cursor: pointer;
vertical-align: middle;
}
}
.el-login-footer {
height: 40px;
line-height: 40px;
position: fixed;
bottom: 0;
width: 100%;
text-align: center;
color: #fff;
font-family: Arial;
font-size: 12px;
letter-spacing: 1px;
}
.login-code-img {
height: 38px;
}
</style>
3.退出登录
this.$keycloak.logoutFn();
将这个方法写到退出的方法中就可以了,因为我们在main.js中做了全局配置了。
参考地址有好多如下,可以结合下面的地址,讲的很详细