一、 概述
本文使用Oauth2开源架构(tkey)、实现单点登录系统。
1. Tkey:
- OAuth 2.0 标准为接口设计原则的单点登录系统(SSO);
- 纯粹的 HTTP,任意设备、任意场景;
- 跨域无状态,随意横向扩展,服务高可用。
2. 选择Tkey
tkey为开源框架,使用方便,易于扩展,完成度高,文档详细。
本文在原有架构基础上,增加了服务端持久层、增加client管理、增加登录页账套查询、优化登录页面、实现客户端跳转。
更多功能请参考原架构
3. Tkey下载地址:
本文代码地址,详见文末。
二、实现单点登录服务端
1)使用架构
- springboot 2.1
- mybatisPlus (mysql)
- tkey (oauth2)
- swagger
- redis
- thymeleaf(登录页)
2)代码参考如下
以下着重介绍修改部分:
- 增加持久层 mybatis
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
@EnableTransactionManagement
@Configuration
public class MybatisPlusConfig {
/**
* 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
return new PaginationInterceptor();
}
}
- ClientController.java
@Slf4j
@RestController
@RequestMapping("/client")
public class ClientController {
@Autowired
private StringRedisService<String, String> clientRedisService;
@Autowired
private OauthClientService oauthClientService;
@PostMapping("/save")
public ResponseEntity<?> save(@RequestBody @Valid OauthClientCreateRequestParam param) {
OauthClientToRedisBO oauthClientToRedisBO = new OauthClientToRedisBO();
BeanUtils.copyProperties(param, oauthClientToRedisBO);
clientRedisService.set(GlobalVariable.REDIS_CLIENT_ID_KEY_PREFIX + oauthClientToRedisBO.getClientId(), JsonUtil.toJson(oauthClientToRedisBO));
return R.success(oauthClientToRedisBO);
}
@GetMapping("/query")
private ResponseEntity<?> query(@RequestParam String clientId) {
return R.success(oauthClientService.findByClientId(clientId));
}
@GetMapping("/delete")
private ResponseEntity<?> delete(@RequestParam String clientId) {
String clientIdRedisKey = GlobalVariable.REDIS_CLIENT_ID_KEY_PREFIX + clientId;
clientRedisService.delete(clientIdRedisKey);
return R.success();
}
}
- 登录初始化
- 登录校验
- 登录页面
<div class="login-wrapper">
<div class="content-left">
</div>
<div class="content-right">
<div class="right-header">
<h1 class="right-header-h2" th:text="${oauthClient != null and oauthClient.clientName != null ? oauthClient.clientName : '登录'}"></h1>
</div>
<div class="right-form-wrapper">
<form onclick="this.disabled=false" onsubmit="return handleSubmit()" method="POST" action="/oauth/authorize" th:action="${#request.getQueryString() != null ? #request.getRequestURL() + '?' + #request.getQueryString() : #request.getRequestURL()}">
<div class="username-wrapper">
<input onfocus="usernameInputFocus()" class="username username-input-default" type="text" name="username" id="username" value="admin" th:value="admin" placeholder="手机 / 邮箱">
<p class="username-error opacity0" th:text="${errorMsg}"></p>
</div>
<div class="password-wrapper">
<input onfocus="passwordInputFocus()" class="password password-input-default" type="password" name="password" id="password" value="123456" th:value="123456" placeholder="请输入密码">
<p class="password-error opacity0">密码为必填项</p>
</div>
<div class="form-group">
<select class="username username-input-default" name="sob" id="sob">
<option th:each="ss:${sobs}" th:value="${ss.id}" th:text="${ss.sobName}"></option>
</select>
</div>
<div class="checkMe">
<span>
<input type="checkbox" value="false" name="bool_is_remember_me" id="bool_is_remember_me">
<label for="bool_is_remember_me" class="ml5">记住我</label>
</span>
<a class="text-login">忘记密码?</a>
</div>
<div class="submit-btn-wrapper">
<img class="btn-loading" th:src="@{/images/loading.svg}" alt="">
<button type="submit" onclick="handleSubmit()" class="submit-btn">登录</button>
</div>
<div class="mt15 tac">
<span class="right-header-span">没有账号? <a>点此注册</a></span>
</div>
</form>
</div>
</div>
</div>
三、实现客户端
- pom.xml
<!-- sso -->
<dependency>
<groupId>com.cdk8s.tkey</groupId>
<artifactId>tkey-sso-client-starter-rest</artifactId>
<version>1.0.0</version>
</dependency>
- 自定义拦截器
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Bean
public LoginInterceptor loginInterceptor() {
return new LoginInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/error", "/logoutSuccess", "login/**","/logout/**","/codeCallback/**","/css/**", "/js/**", "/fonts/**")
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**");
}
}
@Slf4j
public class LoginInterceptor extends HandlerInterceptorAdapter {
@Value("${front_url}")
private String front_url;
@Autowired
private TkeyProperties tkeyProperties;
//=====================================业务处理 start=====================================
private boolean resp(final HttpServletRequest request, final HttpServletResponse response) throws IOException {
if (tkeyProperties.getEnableCodeCallbackToFront()) {
responseJson(response);
} else {
response.sendRedirect(getRedirectUrl(request));
}
return false;
}
@SneakyThrows
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, Object handler) {
String accessToken = CookieUtil.getValue(request, AuthVariable.SSO_SESSIONID);
if (StringUtils.isBlank(accessToken)) {
log.error("【SSO】Local token is null, to login...");
return resp(request, response);
}
// 本机不校验
String local_url = InetAddress.getLocalHost().getHostAddress();
if (request.getRemoteAddr().contains(local_url)) {
log.info("【SSO】Local to pass...");
return true;
}
String token = request.getHeader(AuthVariable.HEADER_TOKEN_KEY);
if (StringUtils.isBlank(token) || !token.equals(accessToken)) {
response.sendRedirect(front_url + accessToken);
return false;
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
}
private String getRedirectUrl(final HttpServletRequest request) {
return tkeyProperties.getFinalRedirectUri(request);
}
@SneakyThrows
private void responseJson(final HttpServletResponse response) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
Map<String, Object> responseMap = new HashMap<>(4);
responseMap.put("isSuccess", false);
responseMap.put("msg", "您还未登录,请先登录");
responseMap.put("timestamp", Instant.now().toEpochMilli());
responseMap.put("code", "0");
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(responseMap);
PrintWriter out = response.getWriter();
out.print(json);
}
}
-
鉴权回调controller
/**
* 接收 code,然后换取 token
*/
@SneakyThrows
@RequestMapping(value = "/codeCallback", method = RequestMethod.GET)
public void codeCallback(final HttpServletRequest request, final HttpServletResponse response, @RequestParam(value = "redirect_uri", required = true) String redirectUri) {
String code = request.getParameter("code");
if (StringUtils.isBlank(code)) {
return;
}
getAccessToken(request, response, code);
// 重定向到原请求地址
redirectUri = CodecUtil.decodeURL(redirectUri);
response.sendRedirect(redirectUri);
}
@RequestMapping(value = "/logout", method = RequestMethod.GET)
public String logout(final HttpServletRequest request, final HttpServletResponse response) {
String finalLogoutUri = tkeyProperties.getFinalLogoutUri();
CookieUtil.remove(request, response, AuthVariable.SSO_SESSIONID);
return "redirect:" + finalLogoutUri;
}
private TkeyToken getAccessToken(HttpServletRequest request, HttpServletResponse response, String code) {
OAuth2AccessToken oauthToken = tkeyService.getAccessToken(code);
String accessToken = oauthToken.getAccessToken();
TkeyToken tkeyToken = new TkeyToken();
tkeyToken.setAccessToken(accessToken);
tkeyToken.setRefreshToken(oauthToken.getRefreshToken());
OauthUserProfile user = tkeyService.getUserProfile(oauthToken);
tkeyToken.setAttributes(user);
CookieUtil.set(response, AuthVariable.SSO_SESSIONID, accessToken, false);
return tkeyToken;
}
-
首页controller(支持前后端分离)
@RequestMapping("/")
public String index(HttpServletRequest request, Model model) {
String accessToken = CookieUtil.getValue(request, AuthVariable.SSO_SESSIONID);
if (front_flag) {
return "redirect:" + front_url + accessToken;
} else {
model.addAttribute("token", accessToken);
return "index";
}
}
四、启动程序
1. 启动redis;
2. 启动mysql:创建数据库,运行脚本。
注:脚本参考文末代码
3. 启动程序. ServerApp、2.ClientApp
客户端port:8081
服务端port:8086
4. 插入client:
调用接口如图:
报文如下:
{
"id": 1001,
"client_name": "client_id_sso_client",
"client_id": "client_id_sso_client",
"client_secret": "client_secret_sso_client",
"client_url": "^(http|https)://.*",
"client_desc": "Client系统"
}
5. 调用客户端
在浏览器地址栏输入:http://127.0.0.1:8081/client
统一跳转到鉴权服务器,如图
注:账套为本文自定义信息,可酌情删除。
用户名:admin 、密码:123456
登录成功:
以上,登录成功。
注:如果使用前后端分离系统,请在配置中增加前端首页地址
登录成功后,将token返回前端即可。
本文源码地址:
https://gitee.com/zetor2020/ym-paas-sso-tkey.git
下载代码的朋友点下star,多谢支持
点赞本文,再次感谢