初识SpringSecurity
参考
来源于黑马程序员: 手把手教你精通新版SpringSecurity
权限管理概念
权限管理,一般指根据系统设置的安全规则或者安全策略,用户可以访问而且只能访问自己被授权的资源。权限管
理几乎出现在任何系统里面,前提是需要有用户和密码认证的系统。
在权限管理的概念中,有两个非常重要的名词:
- 认证:通过用户名和密码成功登陆系统后,让系统得到当前用户的角色身份。
- 授权:系统根据当前用户的角色,给其授予对应可以操作的权限资源。
完成权限管理需要三个对象
- 用户:主要包含用户名,密码和当前用户的角色信息,可实现认证操作。
- 角色:主要包含角色名称,角色描述和当前角色拥有的权限信息,可实现授权操作。
- 权限:权限也可以称为菜单,主要包含当前权限名称,url地址等信息,可实现动态展示菜单。
注:这三个对象中,用户与角色是多对多的关系,角色与权限是多对多的关系,用户与权限没有直接关系,
二者是通过角色来建立关联关系的。
初识SpringSecurity
Spring Security是spring采用AOP思想,基于servlet过滤器实现的安全框架。它提供了完善的认证机制和方法级的
授权功能。是一款非常优秀的权限管理框架。
创建SpringSecurity
Spring Security主要jar包功能介绍
- spring-security-core.jar:核心包,任何Spring Security功能都需要此包。
- spring-security-web.jar:web工程必备,包含过滤器和相关的Web安全基础结构代码。
- spring-security-config.jar:用于解析xml配置文件,用到Spring Security的xml配置文件的就要用到此包。
- spring-security-taglibs.jar:Spring Security提供的动态标签库,jsp页面可以用。
导入pom依赖
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
<version>5.1.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.1.5.RELEASE</version>
</dependency>
最终依赖树
配置web.xml
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<display-name>Archetype Created Web Application</display-name>
<!--Spring Security过滤器链,注意过滤器名称必须叫springSecurityFilterChain-->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
配置SpringSecurity.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security.xsd">
<!--设置可以用spring的el表达式配置Spring Security并自动生成对应配置组件(过滤器)-->
<security:http auto-config="true" use-expressions="true">
<!--使用spring的el表达式来指定项目所有资源访问都必须有ROLE_USER或ROLE_ADMIN角色-->
<security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER','ROLE_ADMIN')"/>
</security:http>
<!--设置Spring Security认证用户信息的来源 {noop}表示不加密认证-->
<security:authentication-manager>
<security:authentication-provider>
<security:user-service>
<security:user name="user" password="{noop}user"
authorities="ROLE_USER" />
<security:user name="admin" password="{noop}admin"
authorities="ROLE_ADMIN" />
</security:user-service>
</security:authentication-provider>
</security:authentication-manager>
</beans>
SpringSecurity常用过滤器介绍
过滤器是一种典型的AOP思想
SecurityContextPersistenceFilter
首当其冲的一个过滤器,作用之重要,自不必多言。
SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存或更新一SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文。SecurityContext中存储了当前用户的认证以及权限信息。
WebAsyncManagerIntegrationFilter
此过滤器用于集成SecurityContext到Spring异步执行机制中的WebAsyncManager
HeaderWriterFilter
向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制(仅限于JSP页面)
CsrfFilter
csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,
如果不包含,则报错。起到防止csrf攻击的效果。
LogoutFilter
匹配URL为/logout的请求,实现用户退出,清除认证信息。
UsernamePasswordAuthenticationFilter
认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。
DefaultLoginPageGeneratingFilter
如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。
DefaultLogoutPageGeneratingFilter
由此过滤器可以生产一个默认的退出登录页面
BasicAuthenticationFilter
此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。
RequestCacheAwareFilter
通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest
SecurityContextHolderAwareRequestFilter
针对ServletRequest进行了一次包装,使得request具有更加丰富的API
AnonymousAuthenticationFilter
当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。
spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
当用户以游客身份登录的时候,也就是可以通过设置某些接口可以匿名访问
SessionManagementFilter
SecurityContextRepository限制同一用户开启多个会话的数量
ExceptionTranslationFilter
异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常
FilterSecurityInterceptor
获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权
限
该过滤器限制哪些资源可以访问,哪些不能够访问
SpringSecurity过滤器链加载原理
通过前面十五个过滤器功能的介绍,对于SpringSecurity简单入门中的疑惑是不是在心中已经有了答案了呀?
但新的问题来了!我们并没有在web.xml中配置这些过滤器啊?它们都是怎么被加载出来的?
DelegatingFilterProxy
我们在web.xml中配置了一个名称为springSecurityFilterChain的过滤器DelegatingFilterProxy,接下我直接对
DelegatingFilterProxy源码里重要代码进行说明,其中删减掉了一些不重要的代码,大家注意我写的注释就行了!
第二步debug结果如下
由此可知,DelegatingFilterProxy通过springSecurityFilterChain这个名称,得到了一个FilterChainProxy过滤器,最终在第三步执行了这个过滤器。
FilterChainProxy
注意代码注释!注意代码注释!注意代码注释!
第二步debug结果如下图所示,惊不惊喜?十五个过滤器都在这里了!
再看第三步,怀疑这么久!原来这些过滤器还真是都被封装进SecurityFilterChain中了。
SecurityFilterChain
最后看SecurityFilterChain,这是个接口,实现类也只有一个,这才是web.xml中配置的过滤器链对象!
总结
通过此章节,我们对SpringSecurity工作原理有了一定的认识。但理论千万条,功能第一条,探寻底层,是
为了更好的使用框架。
那么,言归正传!到底如何使用自己的页面来实现SpringSecurity的认证操作呢?要完成此功能,首先要有一套
自己的页面!
SpringSecurity使用自定义认证页面
在SpringSecurity主配置文件中指定认证页面配置信息
修改认证页面的请求地址
再次启动项目后就可以看到自定义的酷炫认证页面了!
然后你开开心心的输入了用户名user,密码user,就出现了如下的界面:
403什么异常?这是SpringSecurity中的权限不足!这个异常怎么来的?还记得上面SpringSecurity内置认证页面源码中的那个_csrf隐藏input吗?问题就在这了!
完整的spring-security配置如下
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/mvc
http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security.xsd">
<!--释放静态资源-->
<security:http pattern="/css/**" security="none"/>
<security:http pattern="/img/**" security="none"/>
<security:http pattern="/plugins/**" security="none"/>
<security:http pattern="/failer.jsp" security="none"/>
<!--配置springSecurity-->
<!--
auto-config="true" 表示自动加载springsecurity的配置文件
use-expressions="true" 表示使用spring的el表达式来配置springsecurity
-->
<security:http auto-config="true" use-expressions="true">
<!--让认证页面可以匿名访问-->
<security:intercept-url pattern="/login.jsp" access="permitAll()"/>
<!--拦截资源-->
<!--
pattern="/**" 表示拦截所有资源
access="hasAnyRole('ROLE_USER')" 表示只有ROLE_USER角色才能访问资源
-->
<security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER')"/>
<!--配置认证信息-->
<security:form-login login-page="/login.jsp"
login-processing-url="/login"
default-target-url="/index.jsp"
authentication-failure-url="/failer.jsp"/>
<!--配置退出登录信息-->
<security:logout logout-url="/logout"
logout-success-url="/login.jsp"/>
<!--去掉csrf拦截的过滤器-->
<!--<security:csrf disabled="true"/>-->
</security:http>
<!--把加密对象放入的IOC容器中-->
<bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
<!--设置Spring Security认证用户信息的来源-->
<!--
springsecurity默认的认证必须是加密的,加上{noop}表示不加密认证。
-->
<security:authentication-manager>
<security:authentication-provider user-service-ref="userServiceImpl">
<security:password-encoder ref="passwordEncoder"/>
</security:authentication-provider>
</security:authentication-manager>
</beans>
SpringSecurity的csrf防护机制
CSRF(Cross-site request forgery)跨站请求伪造,是一种难以防范的网络攻击方式。
SpringSecurity中CsrfFilter过滤器说明
通过源码分析,我们明白了,自己的认证页面,请求方式为POST,但却没有携带token,所以才出现了403权限不
足的异常。那么如何处理这个问题呢?
方式一:直接禁用csrf,不推荐。
方式二:在认证页面携带token请求。
禁用csrf防护机制
在SpringSecurity主配置文件中添加禁用crsf防护的配置。
在认证页面携带token请求
注:HttpSessionCsrfTokenRepository对象负责生成token并放入session域中。
SpringSecurity使用数据库数据完成认证
认证流程
先看主要负责认证的过滤器UsernamePasswordAuthenticationFilter,有删减,注意注释。
上面的过滤器的意思就是,我们发送的登录请求,请求地址需要是 /login,请求方法 post,然后用户名 username,密码为 password
AuthenticationManager
由上面源码得知,真正认证操作在AuthenticationManager里面!然后看AuthenticationManager的实现类ProviderManager:
AbstractUserDetailsAuthenticationProvider
咱们继续再找到AuthenticationProvider的实现类AbstractUserDetailsAuthenticationProvider
AbstractUserDetailsAuthenticationProvider中authenticate返回值
按理说到此已经知道自定义认证方法的怎么写了,但咱们把返回的流程也大概走一遍,上面不是说到返回了一个
UserDetails对象对象吗?跟着它,就又回到了AbstractUserDetailsAuthenticationProvider对象中authenticate方法的最后一行了。
UsernamePasswordAuthenticationToken
来到UsernamePasswordAuthenticationToken对象发现里面有两个构造方法
AbstractAuthenticationToken
再点进去super(authorities)看看:
由此,咱们需要牢记自定义认证业务逻辑返回的UserDetails对象中一定要放置权限信息啊!
现在可以结束源码分析了吧?先不要着急!
咱们回到最初的地方UsernamePasswordAuthenticationFilter,你看好看了,这可是个过滤器,咱们分析这么
久,都没提到doFilter方法,你不觉得心里不踏实?
可是这里面也没有doFilter呀?那就从父类找!
AbstractAuthenticationProcessingFilter
点开AbstractAuthenticationProcessingFilter,删掉不必要的代码!
可见AbstractAuthenticationProcessingFilter这个过滤器对于认证成功与否,做了两个分支,成功执行
successfulAuthentication,失败执行unsuccessfulAuthentication。
在successfulAuthentication内部,将认证信息存储到了SecurityContext中。并调用了loginSuccess方法,这就是
常见的“记住我”功能!此功能具体应用,咱们后续再研究!
初步实现认证功能
让我们自己的UserService接口继承UserDetailsService,毕竟SpringSecurity是只认UserDetailsService的:
创建UserDetailsService
public interface UserService extends UserDetailsService {
public void save(SysUser user);
public List<SysUser> findAll();
public Map<String, Object> toAddRolePage(Integer id);
public void addRoleToUser(Integer userId, Integer[] ids);
}
编写loadUserByUsername业务
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userDao.findByName(username);
if(sysUser==null){
//若用户名不对,直接返回null,表示认证失败。
return null;
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<SysRole> roles = sysUser.getRoles();
for (SysRole role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
//最终需要返回一个SpringSecurity的UserDetails对象,{noop}表示不加密认证。
return new User(sysUser.getUsername(), "{noop}"+sysUser.getPassword(), authorities);
}
在SpringSecurity主配置文件中指定认证使用的业务对象
<!--设置Spring Security认证用户信息的来源-->
<security:authentication-manager>
<security:authentication-provider user-service-ref="userServiceImpl">
</security:authentication-provider>
</security:authentication-manager>
加密认证
在IOC容器中提供加密对象
<!--加密对象-->
<bean id="passwordEncoder"
class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
<!--设置Spring Security认证用户信息的来源-->
<security:authentication-manager>
<security:authentication-provider user-service-ref="userServiceImpl">
<!--指定认证使用的加密对象-->
<security:password-encoder ref="passwordEncoder"/>
</security:authentication-provider>
</security:authentication-manager>
修改认证方法
去掉{noop},该方法可以让我们的密码不加密
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = userDao.findByName(username);
if(sysUser==null){
//若用户名不对,直接返回null,表示认证失败。
return null;
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<SysRole> roles = sysUser.getRoles();
for (SysRole role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
//最终需要返回一个SpringSecurity的UserDetails对象,{noop}表示不加密认证。
return new User(sysUser.getUsername(), sysUser.getPassword(), authorities);
}
修改添加用户的操作
@Service
@Transactional
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao;
@Autowired
private RoleService roleService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
@Override
public void save(SysUser user) {
//对密码进行加密,然后再入库
user.setPassword(passwordEncoder.encode(user.getPassword()));
userDao.save(user);
}
}
手动将数据库中用户密码改为加密后的密文
SpringSecurity认证过程
参考
来源于黑马程序员: 手把手教你精通新版SpringSecurity
设置用户状态
用户认证业务里,我们封装User对象时,选择了三个构造参数的构造方法,其实还有另一个构造方法:
public User(String username, String password, boolean enabled, boolean accountNonExpired,
boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority>
authorities) {
if (username != null && !"".equals(username) && password != null) {
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
} else {
throw new IllegalArgumentException("Cannot pass null or empty values to constructor");
}
}
可以看到,这个构造方法里多了四个布尔类型的构造参数,其实我们使用的三个构造参数的构造方法里这四个布尔
值默认都被赋值为了true,那么这四个布尔值到底是何意思呢?
- boolean enabled 是否可用
- boolean accountNonExpired 账户是否失效
- boolean credentialsNonExpired 密码是否失效
- boolean accountNonLocked 账户是否锁定
这样定义,然后在通过下面代码来进行判断该用户是否处于正常
/**
* 通过管理员Admin,生成一个SpringSecurity用户
* @param admin
* @return
*/
public static SecurityUser create(Admin admin) {
boolean enabled = admin.getStatus() == EStatus.ENABLE;
return new SecurityUser(
admin.getUid(),
admin.getUserName(),
admin.getPassWord(),
enabled,
mapToGrantedAuthorities(admin.getRoleNames())
);
}
注销功能
注意:一旦开启了csrf防护功能,logout处理器便只支持POST请求方式了!修改header.jsp中注销请求:
<form action="${pageContext.request.contextPath}/logout" method="post">
<security:csrfInput/>
<input type="submit" value="注销">
</form>
记住我功能
流程分析
还记得前面咱们分析认证流程时,提到的记住我功能吗?现在继续跟踪找到AbstractRememberMeServices对象的loginSuccess方法:
再点进去上面if判断中的rememberMeRequested方法,还在当前类中
如果上面方法返回true,就表示页面勾选了记住我选项了。继续顺着调用的方法找到PersistentTokenBasedRememberMeServices的onLoginSuccess方法:
记住我功能页面代码
注意name和value属性的值不要写错哦!
先测试一下,认证通过后,关掉浏览器,再次打开页面,发现还要认证!为什么没有起作用呢?这是因为remember me功能使用的过滤器RememberMeAuthenticationFilter默认是不开启的!
开启remember me过滤器
<!--设置可以用spring的el表达式配置Spring Security并自动生成对应配置组件(过滤器)-->
<security:http auto-config="true" use-expressions="true">
<!--省略其余配置-->
<!--开启remember me过滤器,设置token存储时间为60秒-->
<security:remember-me token-validity-seconds="60"/>
</security:http>
说明:RememberMeAuthenticationFilter中功能非常简单,会在打开浏览器时,自动判断是否认证,如果没有则
调用autoLogin进行自动认证。
remember me安全性分析
记住我功能方便是大家看得见的,但是安全性却令人担忧。因为Cookie毕竟是保存在客户端的,很容易盗取,而且 Cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全。那么 这就要提醒喜欢使用此功能的,用完网站要及时手动退出登录,清空认证信息。
此外,SpringSecurity还提供了remember me的另一种相对更安全的实现机制 :在客户端的cookie中,仅保存一个
无意义的加密串(与用户名、密码等敏感数据无关),然后在db中保存该加密串-用户信息的对应关系,自动登录
时,用cookie中的加密串,到db中验证,如果通过,自动登录才算通过。
持久化remember me信息
创建一张表,注意这张表的名称和字段都是固定的,不要修改。
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
然后将spring-security.xml中 改为:
<!--
开启remember me过滤器,
data-source-ref="dataSource" 指定数据库连接池
token-validity-seconds="60" 设置token存储时间为60秒 可省略
remember-me-parameter="remember-me" 指定记住的参数名 可省略
-->
<security:remember-me data-source-ref="dataSource"
token-validity-seconds="60" remember-me-parameter="remember-me"/>
最后测试发现数据库中自动多了一条记录:
显示当前认证信息
在header.jsp中找到页面头部最右侧图片处添加如下信息:
<span class="hidden-xs">
<security:authentication property="principal.username" />
</span>
或者
<span class="hidden-xs">
<security:authentication property="name" />
</span>
授权准备工作
为了模拟授权操作,咱们临时编写两个业务功能:处理器代码:
aside.jsp页面:
动态展示菜单
在aside.jsp对每个菜单通过SpringSecurity标签库指定访问所需角色
我们做个测试,xiaozhi这个用户现在只有普通用户角色ROLE_USER,用xiaozhi登录后,果然只看到了订单管理:
那么问题来了,是不是现在已经授权成功了呢?答案是否定的!你可以试试直接去访问产品的http请求地址:
我们发现xiaozhi其实是可以操作产品模块的,只是没有把产品功能展示给xiaozhi而已!
总结一句:页面动态菜单的展示只是为了用户体验,并未真正控制权限!
授权操作
IOC容器介绍
从这里我们就可以知道,我们的spring-security.xml需要放到父容器中被保护起来,不能放到子容器中被直接访问
说明:SpringSecurity可以通过注解的方式来控制类或者方法的访问权限。注解需要对应的注解支持,若注解放在
controller类中,对应注解支持应该放在mvc配置文件中,因为controller类是有mvc配置文件扫描并创建的,同
理,注解放在service类中,对应注解支持应该放在spring配置文件中。由于我们现在是模拟业务操作,并没有
service业务代码,所以就把注解放在controller类中了。
开启授权的注解支持
这里给大家演示三类注解,但实际开发中,用一类即可!
<!--
开启权限控制注解支持
jsr250-annotations="enabled" 表示支持jsr250-api的注解,需要jsr250-api的jar包
pre-post-annotations="enabled" 表示支持spring表达式注解
secured-annotations="enabled" 这才是SpringSecurity提供的注解
-->
<security:global-method-security jsr250-annotations="enabled"
pre-post-annotations="enabled"
secured-annotations="enabled"/>
在注解支持对应类或者方法上添加注解
我们也可以使用多种注解的方式
@Controller
@RequestMapping("/product")
public class ProductController {
//@Secured({"ROLE_PRODUCT","ROLE_ADMIN"})//springSecurity内部制定的注解
//@RolesAllowed({"ROLE_PRODUCT","ROLE_ADMIN"})//jsr250注解
@PreAuthorize("hasAnyAuthority('ROLE_PRODUCT','ROLE_ADMIN')")//spring的el表达式注解
@RequestMapping("/findAll")
public String findAll(){
return "product-list";
}
}
权限不足异常处理
大家也发现了,每次权限不足都出现403页面,着实难堪!体会一下:
用户体验不是很好,现在我们立马消灭它!
方式一:在spring-security.xml配置文件中处理
<!--设置可以用spring的el表达式配置Spring Security并自动生成对应配置组件(过滤器)-->
<security:http auto-config="true" use-expressions="true">
<!--省略其它配置-->
<!--403异常处理-->
<security:access-denied-handler error-page="/403.jsp"/>
</security:http>
方式二:在web.xml中处理
<error-page>
<error-code>403</error-code>
<location>/403.jsp</location>
</error-page>
方式三:编写异常处理器
拦截器和过滤器的区别
- 拦截器:可以在Spring中进行使用
- 过滤器:只能在web.xml中进行配置,也就是只能在web工程中使用
或者我们可以实现一个Spring给我们提供好的接口
@Component
public class HandlerControllerException implements HandlerExceptionResolver {
/**
* @param httpServletRequest
* @param httpServletResponse
* @param o 出现异常的对象
* @param e 出现的异常信息
* @return ModelAndView
*/
@Override
public ModelAndView resolveException(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) {
ModelAndView mv = new ModelAndView();
//将异常信息放入request域中,基本不用
mv.addObject("errorMsg", e.getMessage());
//指定不同异常跳转的页面
if(e instanceof AccessDeniedException){
mv.setViewName("redirect:/403.jsp");
}else {
mv.setViewName("redirect:/500.jsp");
}
return mv;
}
}
下面一个更简单的方式,通过注解就相当于我们实现了 HandlerExceptionResolver
@ControllerAdvice
public class HandlerControllerAdvice{
@ExceptionHandler(AccessDeniedException.class)
public String handlerException(){
return "redirect:/403.jsp";
}
@ExceptionHandler(RuntimeException.class)
public String runtimeHandlerException(){
return "redirect:/500.jsp";
}
}
SpringSecurity在单机环境下使用
参考
来源于黑马程序员: 手把手教你精通新版SpringSecurity
技术选型
SpringBoot2.1.3,SpringSecurity,MySQL,mybatis,jsp
初步整合认证第一版
创建工程并导入jar包
先只导入SpringBoot
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version> 从中央仓库找,不从本地找
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
提供处理器
@Controller
@RequestMapping("/product")
public class ProductController {
@RequestMapping
@ResponseBody
public String hello(){
return "success";
}
}
编写启动类
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}
测试效果
使用SpringBoot内置tomcat启动项目,即可访问处理器。
加入SpringSecurity的jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
重启再次测试
SpringBoot已经为SpringSecurity提供了默认配置,默认所有资源都必须认证通过才能访问。
那么问题来了!此刻并没有连接数据库,也并未在内存中指定认证用户,如何认证呢?
其实SpringBoot已经提供了默认用户名user,密码在项目启动时随机生成,如图:
认证通过后可以继续访问处理器资源:
整合认证第二版
加入jsp,使用自定义认证页面
说明
SpringBoot官方是不推荐在SpringBoot中使用jsp的,那么到底可以使用吗?答案是肯定的!
不过需要导入tomcat插件启动项目,不能再用SpringBoot默认tomcat了。
我们不能 通过启动类的方式来进行启动了,因为它会不识别Jsp页面,必须导入jar包,然后更换方法
导入SpringBoot的tomcat启动插件jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
加入jsp页面等静态资源
在src/main目录下创建webapp目录
这时webapp目录并不能正常使用,因为只有web工程才有webapp目录,在pom文件中修改项目为web工程
这时webapp目录,可以正常使用了!
导入第一天案例中静态资源,注意WEB-INF就不用了哈!
修改login.jsp中认证的url地址
修改header.jsp中退出登录的url地址
提供SpringSecurity配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//指定认证对象的来源
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
//SpringSecurity配置信息
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login.jsp", "failer.jsp", "/css/**", "/img/**", "/plugins/**").permitAll()
.antMatchers("/product").hasAnyRole("USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.jsp")
.loginProcessingUrl("/login")
.successForwardUrl("/index.jsp")
.failureForwardUrl("/failer.jsp")
.and()
.logout()
.logoutSuccessUrl("/logout")
.invalidateHttpSession(true)
.logoutSuccessUrl("/login.jsp")
.and()
.csrf()
.disable();
}
}
修改产品处理器
有页面了,就跳转一个真的吧!
@Controller
@RequestMapping("/product")
public class ProductController {
@RequestMapping("/findAll")
public String findAll(){
return "product-list";
}
}
配置视图解析器
使用tomcat插件启动项目
测试效果
自定义的认证页面
认证成功页面
整合认证第三版【数据库认证】
数据库环境准备
依然使用security_authority数据库,sql语句在第一天资料里。
导入数据库操作相关jar包
<!--MySQL驱动包-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<!--springboot启动类-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>2.1.5</version>
</dependency>
<!--导入通用Mapper-->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
<version>2.1.5</version>
</dependency>
在配置文件中添加数据库操作相关配置
在启动类上添加扫描dao接口包注解
创建用户pojo对象
这里直接实现SpringSecurity的用户对象接口,并添加角色集合私有属性。注意接口属性都要标记不参与json的处理
@Data
public class SysRole implements GrantedAuthority {
private Integer id;
private String roleName;
private String roleDesc;
}
创建角色pojo对象
这里直接使用SpringSecurity的角色规范,我们实现UserDetails的类型
@Data
public class SysUser implements UserDetails {
private Integer id;
private String username;
private String password;
private Integer status;
private List<SysRole> roles;
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
提供角色mapper接口
public interface RoleMapper extends Mapper<SysRole> {
@Select("SELECT r.id, r.role_name roleName, r.role_desc roleDesc " +
"FROM sys_role r, sys_user_role ur " +
"WHERE r.id=ur.rid AND ur.uid=#{uid}")
public List<SysRole> findByUid(Integer uid);
}
提供用户mapper接口
这里就用到了Mybatis的一对多进行操作
public interface UserMapper extends Mapper<SysUser> {
@Select("select * from sys_user where username = #{username}")
@Results({
@Result(id = true, property = "id", column = "id"),
@Result(property = "roles", column = "id", javaType = List.class,
many = @Many(select = "com.itheima.mapper.RoleMapper.findByUid"))
})
public SysUser findByName(String username);
}
提供认证service接口
@Service
@Transactional
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
return userMapper.findByUsername(s);
}
}
在启动类中把加密对象放入IOC容器
@SpringBootApplication
@MapperScan("com.itheima.mapper")
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
修改配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//指定认证对象的来源
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
//SpringSecurity配置信息
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login.jsp", "failer.jsp", "/css/**", "/img/**", "/plugins/**").permitAll()
.antMatchers("/product").hasAnyRole("USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.jsp")
.loginProcessingUrl("/login")
.successForwardUrl("/index.jsp")
.failureForwardUrl("/failer.jsp")
.and()
.logout()
.logoutSuccessUrl("/logout")
.invalidateHttpSession(true)
.logoutSuccessUrl("/login.jsp")
.and()
.csrf()
.disable();
}
}
大功告成尽管测试,注意还是用插件启动项目,使用数据库表中的用户名和密码。
整合实现授权功能
在启动类上添加开启方法级的授权注解
在产品处理器类上添加注解
要求产品列表功能必须具有ROLE_ADMIN角色才能访问!
重启项目测试
再次访问产品列表发现权限不足
指定自定义异常页面
编写异常处理器拦截403异常
@ControllerAdvice
public class HandleControllerException {
@ExceptionHandler(RuntimeException.class)
public String exceptionHandler(RuntimeException e){
if(e instanceof AccessDeniedException){
//如果是权限不足异常,则跳转到权限不足页面!
return "redirect:/403.jsp";
}
//其余的异常都到500页面!
return "redirect:/500.jsp";
}
}
再次测试产品列表就可以到自定义异常页面了
SpringSecurity在分布式环境下的使用
参考
来源于黑马程序员: 手把手教你精通新版SpringSecurity
分布式认证概念说明
分布式认证,即我们常说的单点登录,简称SSO,指的是在多应用系统的项目中,用户只需要登录一次,就可以访
问所有互相信任的应用系统。
分布式认证流程图
首先,我们要明确,在分布式项目中,每台服务器都有各自独立的session,而这些session之间是无法直接共享资
源的,所以,session通常不能被作为单点登录的技术方案。最合理的单点登录方案流程如下图所示:
总结一下,单点登录的实现分两大环节:
- **用户认证:**这一环节主要是用户向认证服务器发起认证请求,认证服务器给用户返回一个成功的令牌token,
主要在认证服务器中完成,即图中的A系统,注意A系统只能有一个。 - **身份校验:**这一环节是用户携带token去访问其他服务器时,在其他服务器中要对token的真伪进行检验,主
要在资源服务器中完成,即图中的B系统,这里B系统可以有很多个。
JWT介绍
概念说明
从分布式认证流程中,我们不难发现,这中间起最关键作用的就是token,token的安全与否,直接关系到系统的
健壮性,这里我们选择使用JWT来实现token的生成和校验。
JWT,全称JSON Web Token,官网地址https://jwt.io,是一款出色的分布式身份校验方案。可以生成token,也可以解析检验token。
JWT生成的token由三部分组成
- 头部:主要设置一些规范信息,签名部分的编码格式就在头部中声明。
- 载荷:token中存放有效信息的部分,比如用户名,用户角色,过期时间等,但是不要放密码,会泄露!
- 签名:将头部与载荷分别采用base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行编
码,就得到了签名。【通过随机盐在进行加密】
JWT生成token的安全性分析
从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!
试想:如果生成token所用的盐与解析token时加入的盐是一样的。岂不是类似于中国人民银行把人民币防伪技术
公开了?大家可以用这个盐来解析token,就能用来伪造token。这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!
非对称加密RSA介绍
- **基本原理:**同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 私钥加密,持有私钥或公钥才可以解密
- 公钥加密,持有私钥才可解密
- 优点:安全,难以破解
- 缺点:算法比较耗时,为了安全,可以接受
- 历史:三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三
个人的名字缩写:RSA。
【总结】:也就是说,我们加密信息的时候,使用的是公钥,而验证token真伪的时候,使用的是公钥
JWT相关工具类
jar包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
载荷对象
/**
* 为了方便后期获取token中的用户信息,将token中载荷部分单独封装成一个对象
*/
@Data
public class Payload<T> {
}
JWT工具类
/**
* 生成token以及校验token相关方法
*/
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位分钟
* @return JWT
*/
public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusMinutes(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位秒
* @return JWT
*/
public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusSeconds(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 公钥解析token
*
* @param token 用户请求中的token
* @param publicKey 公钥
* @return Jws<Claims>
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey) {
return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
}
private static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setUserInfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
claims.setExpiration(body.getExpiration());
return claims;
}
/**
* 获取token中的载荷信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
return claims;
}
}
RSA工具类
非对称加密工具列
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 从文件中读取公钥
*
* @param filename 公钥保存路径,相对于classpath
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径,相对于classpath
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
*
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
private static PublicKey getPublicKey(byte[] bytes) throws Exception {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
*
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生存rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @param secret 生成密钥的密文
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
SpringSecurity+JWT+RSA分布式认证思路分析
SpringSecurity主要是通过过滤器来实现功能的!我们要找到SpringSecurity实现认证和校验身份的过滤器!
回顾集中式认证流程
用户认证
使用UsernamePasswordAuthenticationFilter过滤器中attemptAuthentication方法实现认证功能,该过滤
器父类中successfulAuthentication方法实现认证成功后的操作。
身份校验
使用BasicAuthenticationFilter过滤器中doFilterInternal方法验证是否登录,以决定能否进入后续过滤器。
分析分布式认证流程
用户认证
由于,分布式项目,多数是前后端分离的架构设计,我们要满足可以接受异步post的认证请求参数,需要修
改UsernamePasswordAuthenticationFilter过滤器中attemptAuthentication方法,让其能够接收请求体。
另外,默认successfulAuthentication方法在认证通过后,是把用户信息直接放入session就完事了,现在我
们需要修改这个方法,在认证通过后生成token并返回给用户。
身份校验
原来BasicAuthenticationFilter过滤器中doFilterInternal方法校验用户是否登录,就是看session中是否有用
户信息,我们要修改为,验证用户携带的token是否合法,并解析出用户信息,交给SpringSecurity,以便于
后续的授权功能可以正常使用。
SpringSecurity+JWT+RSA分布式认证实现
创建父工程并导入jar包
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.itheima</groupId>
<artifactId>springboot_security_jwt_rsa_parent</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>heima_common</module>
<module>heima_auth_server</module>
<module>heima_source_product</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/>
</parent>
</project>
通用模块
创建通用子模块并导入JWT相关jar包
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springboot_security_jwt_rsa_parent</artifactId>
<groupId>com.itheima</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>heima_common</artifactId>
<dependencies>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<!--jackson包-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.9</version>
</dependency>
<!--日志包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
导入工具类
工具类如下
Payload.java
/**
* 为了方便后期获取token中的用户信息,将token中载荷部分单独封装成一个对象
*/
@Data
public class Payload<T> {
private String id;
private T userInfo;
private Date expiration;
}
JsonUtil.java
public class JsonUtils {
public static final ObjectMapper mapper = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(JsonUtils.class);
public static String toString(Object obj) {
if (obj == null) {
return null;
}
if (obj.getClass() == String.class) {
return (String) obj;
}
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
logger.error("json序列化出错:" + obj, e);
return null;
}
}
public static <T> T toBean(String json, Class<T> tClass) {
try {
return mapper.readValue(json, tClass);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <E> List<E> toList(String json, Class<E> eClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructCollectionType(List.class, eClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <K, V> Map<K, V> toMap(String json, Class<K> kClass, Class<V> vClass) {
try {
return mapper.readValue(json, mapper.getTypeFactory().constructMapType(Map.class, kClass, vClass));
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
public static <T> T nativeRead(String json, TypeReference<T> type) {
try {
return mapper.readValue(json, type);
} catch (IOException e) {
logger.error("json解析出错:" + json, e);
return null;
}
}
}
jwtUitls.java
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位分钟
* @return JWT
*/
public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusMinutes(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 私钥加密token
*
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位秒
* @return JWT
*/
public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(createJTI())
.setExpiration(DateTime.now().plusSeconds(expire).toDate())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 公钥解析token
*
* @param token 用户请求中的token
* @param publicKey 公钥
* @return Jws<Claims>
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey) {
return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
}
private static String createJTI() {
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes()));
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey, Class<T> userType) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setUserInfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(), userType));
claims.setExpiration(body.getExpiration());
return claims;
}
/**
* 获取token中的载荷信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static <T> Payload<T> getInfoFromToken(String token, PublicKey publicKey) {
Jws<Claims> claimsJws = parserToken(token, publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setExpiration(body.getExpiration());
return claims;
}
}
RsaUtils.java
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 从文件中读取公钥
*
* @param filename 公钥保存路径,相对于classpath
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径,相对于classpath
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
*
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
private static PublicKey getPublicKey(byte[] bytes) throws Exception {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
*
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生存rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @param secret 生成密钥的密文
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String secret, int keySize) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
在通用子模块中编写测试类生成rsa公钥和私钥
public class RsaUtilsTest {
private String publicFile = "D:\\auth_key\\rsa_key.pub";
private String privateFile = "D:\\auth_key\\rsa_key";
@Test
public void generateKey() throws Exception {
RsaUtils.generateKey(publicFile, privateFile, "heima", 2048);
}
}
执行后查看D:\auth_key目录发现私钥和公钥文件生成成功
认证服务
创建认证服务工程并导入jar包
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springboot_security_jwt_rsa_parent</artifactId>
<groupId>com.itheima</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>heima_auth_server</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.itheima</groupId>
<artifactId>heima_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
</dependencies>
</project>
创建认证服务配置文件
server:
port: 9001
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///security_authority
username: root
password: root
mybatis:
type-aliases-package: com.itheima.domain
configuration:
map-underscore-to-camel-case: true
logging:
level:
com.itheima: debug
rsa:
key:
pubKeyFile: D:\auth_key\id_key_rsa.pub
priKeyFile: D:\auth_key\id_key_rsa
提供解析公钥和私钥的配置类
@Data
@ConfigurationProperties(prefix = "heima.key")
public class RsaKeyProperties {
private String pubKeyPath;
private String priKeyPath;
private PublicKey publicKey;
private PrivateKey privateKey;
@PostConstruct //对象的初始化完成创建后执行
public void loadKey() throws Exception {
publicKey = RsaUtils.getPublicKey(pubKeyPath);
privateKey = RsaUtils.getPrivateKey(priKeyPath);
}
}
创建认证服务启动类
@SpringBootApplication
@MapperScan("com.itheima.mapper")
@EnableConfigurationProperties(RsaKeyProperties.class)
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}
将上面集中式案例中数据库认证相关代码复制到认证服务中
需要复制的代码如果所示:
注意这里要去掉mapper中继承的通用mapper接口,处理器类上换成@RestController,这里前后端绝对分离,不能再跳转页面了,要返回数据。
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private RsaKeyProperties prop;
public JwtLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) {
this.authenticationManager = authenticationManager;
this.prop = prop;
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
SysUser sysUser = new ObjectMapper().readValue(request.getInputStream(), SysUser.class);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(sysUser.getUsername(), sysUser.getPassword());
return authenticationManager.authenticate(authRequest);
}catch (Exception e){
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
Map resultMap = new HashMap();
resultMap.put("code", HttpServletResponse.SC_UNAUTHORIZED);
resultMap.put("msg", "用户名或密码错误!");
out.write(new ObjectMapper().writeValueAsString(resultMap));
out.flush();
out.close();
}catch (Exception outEx){
outEx.printStackTrace();
}
throw new RuntimeException(e);
}
}
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SysUser user = new SysUser();
user.setUsername(authResult.getName());
user.setRoles((List<SysRole>) authResult.getAuthorities());
String token = JwtUtils.generateTokenExpireInMinutes(user, prop.getPrivateKey(), 24 * 60);
response.addHeader("Authorization", "Bearer "+token);
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map resultMap = new HashMap();
resultMap.put("code", HttpServletResponse.SC_OK);
resultMap.put("msg", "认证通过!");
out.write(new ObjectMapper().writeValueAsString(resultMap));
out.flush();
out.close();
}catch (Exception outEx){
outEx.printStackTrace();
}
}
}
编写检验token过滤器
public class JwtVerifyFilter extends BasicAuthenticationFilter {
private RsaKeyProperties prop;
public JwtVerifyFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) {
super(authenticationManager);
this.prop = prop;
}
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
//如果携带错误的token,则给用户提示请登录!
chain.doFilter(request, response);
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
Map resultMap = new HashMap();
resultMap.put("code", HttpServletResponse.SC_FORBIDDEN);
resultMap.put("msg", "请登录!");
out.write(new ObjectMapper().writeValueAsString(resultMap));
out.flush();
out.close();
} else {
//如果携带了正确格式的token要先得到token
String token = header.replace("Bearer ", "");
//验证tken是否正确
Payload<SysUser> payload = JwtUtils.getInfoFromToken(token, prop.getPublicKey(), SysUser.class);
SysUser user = payload.getUserInfo();
if(user!=null){
UsernamePasswordAuthenticationToken authResult = new UsernamePasswordAuthenticationToken(user.getUsername(), null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authResult);
chain.doFilter(request, response);
}
}
}
}
编写SpringSecurity配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Autowired
private RsaKeyProperties prop;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//指定认证对象的来源
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
//SpringSecurity配置信息
public void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeRequests()
.antMatchers("/product").hasAnyRole("USER")
.anyRequest()
.authenticated()
.and()
.addFilter(new JwtLoginFilter(super.authenticationManager(), prop))
.addFilter(new JwtVerifyFilter(super.authenticationManager(), prop))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
启动测试认证服务
认证请求
认证通过结果
token在Headers中:
验证认证请求
资源服务
说明
资源服务可以有很多个,这里只拿产品服务为例,记住,资源服务中只能通过公钥验证认证。不能签发token!
创建产品服务并导入jar包
根据实际业务导包即可,咱们就暂时和认证服务一样了。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springboot_security_jwt_rsa_parent</artifactId>
<groupId>com.itheima</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>heima_source_product</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.itheima</groupId>
<artifactId>heima_common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
</dependencies>
</project>
编写产品服务配置文件
切记这里只能有公钥地址!
server:
port: 9002
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///security_authority
username: root
password: root
mybatis:
type-aliases-package: com.itheima.domain
configuration:
map-underscore-to-camel-case: true
logging:
level:
com.itheima: debug
rsa:
key:
pubKeyFile: D:\auth_key\id_key_rsa.pub
编写读取公钥的配置类
@ConfigurationProperties("rsa.key")
public class RsaKeyProperties {
private String pubKeyFile;
private PublicKey publicKey;
@PostConstruct
public void createRsaKey() throws Exception {
publicKey = RsaUtils.getPublicKey(pubKeyFile);
}
public String getPubKeyFile() {
return pubKeyFile;
}
public void setPubKeyFile(String pubKeyFile) {
this.pubKeyFile = pubKeyFile;
}
public PublicKey getPublicKey() {
return publicKey;
}
public void setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;
}
}
编写启动类
@SpringBootApplication
@MapperScan("com.itheima.mapper")
@EnableConfigurationProperties(RsaKeyProperties.class)
public class AuthSourceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthSourceApplication.class, args);
}
}
复制认证服务中,用户对象,角色对象和校验认证的接口
这时目录结构如图:
复制认证服务中SpringSecurity配置类做修改,去掉“增加自定义认证过滤器”即可!
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled=true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private RsaKeyProperties prop;
//SpringSecurity配置信息
public void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeRequests()
.antMatchers("/product").hasAnyRole("USER")
.anyRequest()
.authenticated()
.and()
.addFilter(new JwtVerifyFilter(super.authenticationManager(), prop))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
编写产品处理器
@RestController
@RequestMapping("/product")
public class ProductController {
@GetMapping
public String findAll(){
return "产品测试成功!";
}
}
启动产品服务做测试
携带token
在产品处理器上添加访问需要ADMIN角色
@RestController
@RequestMapping("/product")
public class ProductController {
@Secured("ROLE_ADMIN")
@GetMapping
public String findAll(){
return "产品测试成功!";
}
}
重启测试权限不足
在数据库中手动给用户添加ADMIN角色
重新认证获取新token再测试OK了!
https://blog.youkuaiyun.com/weixin_41949328/article/details/104526631