目前系统都是比较流行的微服务架构,在企业发展初期,企业使用的系统很少,通常一个或者两个,每个系统都有自己的登录模块,使用人员每天用自己的账号登录,很方便。但随着企业的发展,用到的微服务系统随之增多,使用人员在操作不同的系统时,需要多次登录,而且每个系统的账号都不一样,都要记录,这对于使用人员来说,很不方便还不友好。于是,就想到是不是可以在一个统一登录门户平台登录,其他系统就不用登录了呢?这就是单点登录要解决的问题。
单点登录英文全称Single sign On,简称就是SSO。它的解释是:在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。
使用当下最流行的SpringBoot2技术,持久层使用MyBatis,权限控制Spring Security,数据库MySql,基于OAuth2认证授权协议,构建一个易理解、高可用、高扩展性的分布式单点登录应用基层。
OAuth2为我们提供了四种授权方式:
授权码模式:授权码相对其他三种来说是功能比较完整、流程最安全严谨的授权方式,通过客户端的后台服务器与服务提供商的认证服务器交互来完成。
简化模式:这种模式不通过服务器端程序来完成,直接由浏览器发送请求获取令牌,令牌是完全暴露在浏览器中的,这种模式不太安全。
密码模式:密码模式也是比较常用到的一种,客户端向授权服务器提供用户名、密码然后得到授权令牌。这种模式不过有种弊端,我们的客户端需要存储用户输入的密码,但是对于用户来说信任度不高的平台是不可能让他们输入密码的。
客户端模式:客户端模式是客户端以自己的名义去授权服务器申请授权令牌,并不是完全意义上的授权。
上述简单的介绍了OAuth2内部的四种授权方式,我们下面使用授权码模式进行微服务单点登录实现,并且我们使用数据库存储用户登录信息、客户端授权信息。
创建Spring Boot2项目,版本:2.3.0,项目名称:cloud-sso-serve
org.springframework.security.oauth.boot
spring-security-oauth2-autoconfigure
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-security
server.port=8087
server.servlet.context-path=/sso
#启用优雅关机
server.shutdown=graceful
#缓冲10秒
spring.lifecycle.timeout-per-shutdown-phase=10s
# mysql连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/sso?useUnicode=true&characterEncoding=UTF-8&userSSL=false&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
# druid web页面
druid.login.enabled=false
druid.login.username=druid
druid.login.password=druid
#druid连接池
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.initialSize=20
spring.datasource.minIdle=30
spring.datasource.maxActive=50
spring.datasource.maxWait=60000
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis=300000
spring.datasource.validationQuery=SELECT 'x'
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=true
spring.datasource.testOnReturn=true
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=20
spring.datasource.filters=stat
spring.datasource.connectionProperties:druid.stat.slowSqlMillis=5000
# MyBatis 配置
mybatis.mapper-locations=classpath*:mapper/**/*Mapper.xml
# mybatis-plus 配置
mybatis-plus.mapper-locations=classpath*:mapper/**/*Mapper.xml
mybatis.configuration.jdbc-type-for-null=null
# thymeleaf 模板引擎配置
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.servlet.content-type=text/html
#开发时关闭缓存,不然没法看到实时页面
spring.thymeleaf.cache=false
#页面的存放路径就使用默认配置了
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.check-template-location=true
spring.thymeleaf.suffix=.html
CREATE TABLE `oauth_client_details` (
`client_id` VARCHAR(256) CHARACTER SET utf8 NOT NULL COMMENT '客户端唯一标识ID',
`resource_ids` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端所能访问的资源id集合',
`client_secret` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端访问密匙',
`scope` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端申请的权限范围',
`authorized_grant_types` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端授权类型',
`web_server_redirect_uri` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端的重定向URI',
`authorities` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '客户端所拥有的权限值',
`access_token_validity` INT(11) DEFAULT NULL COMMENT '客户端access_token的有效时间(单位:秒)',
`refresh_token_validity` INT(11) DEFAULT NULL,
`additional_information` VARCHAR(4096) CHARACTER SET utf8 DEFAULT NULL COMMENT '预留的字段',
`autoapprove` VARCHAR(256) CHARACTER SET utf8 DEFAULT NULL COMMENT '是否跳过授权(true是,false否)',
PRIMARY KEY (`client_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COMMENT='客户端授权表'
insert into `oauth_client_details`
(`client_id`,`resource_ids`,`client_secret`,`scope`,`authorized_grant_types`,`web_server_redirect_uri`,`authorities`,`access_token_validity`,`refresh_token_validity`,`additional_information`,`autoapprove`) values
('client1',NULL,'$2a$10$zLD4yC3sL.n58Fh52EN3C.CKloW6GN3QeJrNPfGaqotaH04M2Ssm6','all','authorization_code,refresh_token','http://localhost:8086/client1/login',NULL,7200,NULL,NULL,'true'),
('client2',NULL,'$2a$10$zLD4yC3sL.n58Fh52EN3C.CKloW6GN3QeJrNPfGaqotaH04M2Ssm6','all','authorization_code,refresh_token','http://localhost:8085/client2/login',NULL,7200,NULL,NULL,'true');
CREATE TABLE `sys_menu` (
`menu_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
`menu_vice_name` VARCHAR(50) NOT NULL COMMENT '菜单副名称',
`parent_id` BIGINT(20) DEFAULT '0' COMMENT '父菜单ID',
`order_num` INT(4) DEFAULT '0' COMMENT '显示顺序',
`url` VARCHAR(200) DEFAULT '#' COMMENT '请求地址',
`menu_type` CHAR(1) DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)',
`visible` CHAR(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` VARCHAR(100) DEFAULT NULL COMMENT '权限标识',
`icon` VARCHAR(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT '' COMMENT '备注',
PRIMARY KEY (`menu_id`)
) ENGINE=INNODB AUTO_INCREMENT=1091 DEFAULT CHARSET=utf8 COMMENT='菜单权限表'
CREATE TABLE `sys_role` (
`role_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` VARCHAR(30) NOT NULL COMMENT '角色名称',
`role_key` VARCHAR(100) NOT NULL COMMENT '角色权限字符串',
`role_sort` INT(4) NOT NULL COMMENT '显示顺序',
`status` CHAR(1) NOT NULL COMMENT '角色状态(0正常 1停用 2删除)',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`role_id`)
) ENGINE=INNODB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8 COMMENT='角色信息表'
CREATE TABLE `sys_role_menu` (
`role_id` BIGINT(20) NOT NULL COMMENT '角色ID',
`menu_id` BIGINT(20) NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='角色和菜单关联表'
CREATE TABLE `sys_user` (
`user_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`dept_id` BIGINT(20) DEFAULT NULL COMMENT '部门ID',
`login_name` VARCHAR(30) NOT NULL COMMENT '登录名称',
`user_name` VARCHAR(30) DEFAULT NULL COMMENT '用户名称',
`email` VARCHAR(50) DEFAULT '' COMMENT '用户邮箱',
`phone` VARCHAR(11) DEFAULT '' COMMENT '手机号码',
`telephone` VARCHAR(12) DEFAULT '' COMMENT '座机号码',
`duty` VARCHAR(30) DEFAULT '' COMMENT '职务',
`sex` CHAR(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
`avatar` VARCHAR(100) DEFAULT '' COMMENT '头像路径',
`password` VARCHAR(100) DEFAULT '' COMMENT '密码',
`salt` VARCHAR(20) DEFAULT '' COMMENT '盐加密',
`status` CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用 2删除)',
`login_ip` VARCHAR(50) DEFAULT '' COMMENT '最后登陆IP',
`login_date` DATETIME DEFAULT NULL COMMENT '最后登陆时间',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`user_id`)
) ENGINE=INNODB AUTO_INCREMENT=61 DEFAULT CHARSET=utf8 COMMENT='用户信息表'
insert into `sys_user`
(`user_id`,`dept_id`,`login_name`,`user_name`,`email`,`phone`,`telephone`,`duty`,`sex`,`avatar`,`password`,`salt`,`status`,`login_ip`,`login_date`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) values
(1,1,'admin','admin','[email protected]','','','','0','','123','111','0','10.96.217.201','2021-02-25 11:15:51','',NULL,'',NULL,NULL);
CREATE TABLE `sys_user_role` (
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`role_id` BIGINT(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='用户和角色关联表'
1.4.1 AuthorizationServerConfig客户端授权配置
package com.modules.common.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import javax.sql.DataSource;
/**
* 客户端授权配置
*/
@Configuration
@EnableAuthorizationServer // 开启授权服务器
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
/**
* 配置第三方应用,可以放在内存(inMemory),数据库
* 四种授权模式("authorization_code", "password", "client_credentials", "implicit", "refresh_token")
* 1、授权码模式(authorization code)(正宗方式)(支持refresh token)
* 2、密码模式(password)(为遗留系统设计)(支持refresh token)
* 3、客户端模式(client_credentials)(为后台api服务消费者设计)(不支持refresh token)
* 4、简化模式(implicit)(为web浏览器应用设计)(不支持refresh token)
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
/**
* 需要暴露授权服务器端点
*
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.accessTokenConverter(jwtAccessTokenConverter());
endpoints.tokenStore(jwtTokenStore());
// endpoints.tokenServices(defaultTokenServices());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
security.tokenKeyAccess("isAuthenticated()");
}
/**
* 配置TokenStore,有多种实现方式,redis,jwt,jdbc
* @return
*/
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("testKey");
return converter;
}
}
1.4.2 SpringSecurityConfig权限配置
package com.modules.common.config;
import com.modules.system.service.SysLoginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* spring security配置
* @author li'chao
*/
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 自定义用户认证逻辑
*/
@Autowired
private SysLoginService sysLoginService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login")
.and()
.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest()
.authenticated()
.and().csrf().disable().cors();
/* http
.requestMatchers().antMatchers("/oauth/**","/login/**","/logout/**")
.and()
.authorizeRequests()
.antMatchers("/oauth/**").authenticated()
.and()
.formLogin().permitAll();*/
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(sysLoginService).passwordEncoder(passwordEncoder());
}
/**
* 强散列哈希加密实现
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}
}
1.4.3 SysLoginService登录处理业务
package com.modules.system.service;
import com.baomidou.mybatisplus.toolkit.CollectionUtils;
import com.modules.system.entity.SysUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 用户登录 业务层
* @author li'chao
*
*/
@Slf4j
@Component
public class SysLoginService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private SysUserService sysUserService;
@Autowired
private SysMenuService sysMenuService;
@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
// 根据登录名称查询用户信息
SysUser sysUser = sysUserService.selectUserByLoginName(name);
if (null == sysUser) {
log.warn("用户{}不存在", name);
throw new UsernameNotFoundException(name);
}
// 根据用户ID查询权限配置的菜单,获取菜单标识字段perms
List permsList = sysMenuService.selectPermsListByUserId(sysUser.getUserId());
permsList.remove(null);
List authorityList = new ArrayList<>();
if(!CollectionUtils.isEmpty(permsList)){
for(String str : permsList){
authorityList.add(new SimpleGrantedAuthority(str));
}
}
return new User(sysUser.getLoginName(), passwordEncoder.encode(sysUser.getPassword()), authorityList);
}
}
1.4.4 LoginController登录处理
package com.modules.system.controller;
import com.modules.common.web.BaseController;
import com.modules.system.service.SysUserService;
import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 登录管理
* @author lc
*/
@Api(tags = "登录管理")
@Slf4j
@CrossOrigin
@Controller
public class LoginController extends BaseController
{
@Autowired
private SysUserService userService;
/**
* 自定义登录页面
* @return
*/
@GetMapping("/login")
public String login() {
return "login";
}
/**
* 登录成功后显示的首页
* @return
*/
@GetMapping("/")
public String index() {
return "index";
}
@RequestMapping("oauth/exit")
public void exit(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, null, null);
try {
System.out.println(request.getHeader("referer"));
response.sendRedirect(request.getHeader("referer"));
} catch (IOException e) {
e.printStackTrace();
}
}
}
1.4.5 自定义登录页面
Ela Admin - HTML5 Admin Template
统一登录综合管理平台
1.4.6 登录成功后首页
综合管理平台
1.4.7 测试
登录页面:admin/123
登录成功
至此,授权服务实现完成,此间使用授权码模式实现,客户端信息存入数据库,登录页面进行自定义。
创建Spring Boot2项目,版本:2.3.0,项目名称:cloud-goods-client
org.springframework.security.oauth.boot
spring-security-oauth2-autoconfigure
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-security
server:
port: 8086
servlet:
context-path: /client1
auth-server: http://localhost:8087/sso
security:
oauth2:
client:
client-id: client1
client-secret: 123456
user-authorization-uri: ${auth-server}/oauth/authorize
access-token-uri: ${auth-server}/oauth/token
resource:
jwt:
key-uri: ${auth-server}/oauth/token_key
# thymeleaf 模板引擎配置
spring:
thymeleaf:
mode: HTML5
encoding: utf-8
servlet:
content-type: text/html
# 开发时关闭缓存,不然没法看到实时页面
cache: true
# 页面的存放路径就使用默认配置了
prefix: classpath:/templates/
check-template-location: true
suffix: .html
2.3.1 WebSecurityConfigurer配置
package com.modules.config;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableOAuth2Sso
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.antMatcher("/**").authorizeRequests()
.anyRequest().authenticated();
}
}
2.3.2 TestController 测试
package com.modules.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TestController {
@GetMapping("/list")
@PreAuthorize("hasAuthority('ROLE_NORMAL')")
public String list( ) {
return "list";
}
}
2.3.3 自定义模拟列表显示
商品管理系统
商品管理系统 | 退出
这是一个商品管理列表页面
2.3.4 测试
访问客户端 http://localhost:8086/client1/list 自动跳转授权服务器登录页面 http://localhost:8087/upms/login
输入登录名称和密码,自动跳转到客户端 http://localhost:8086/client1/list
访问服务器 http://localhost:8087/upms/
因为我在商品管理系统添加了超链接 http://localhost:8086/client1/list,点击商品管理系统,自动跳转到客户端
至此,客户端微服务集成单点登录完成,多个客户端服务如此集成即可。
源码地址:https://gitee.com/lichao12/spring-boot2-security-oauth2-jwt
参考:OAuth2实现单点登录SSO - 废物大师兄 - 博客园