1000字范文,内容丰富有趣,学习的好帮手!
1000字范文 > 基于Spring Security与JWT实现单点登录

基于Spring Security与JWT实现单点登录

时间:2019-01-04 08:10:21

相关推荐

基于Spring Security与JWT实现单点登录

基于RBAC的权限管理

RBAC(Role-Based Access Control):基于角色的访问控制

当前项目中,RBAC具体的表现为:

管理员表:ams_admin

角色表:ams_role

权限表:ams_permission

管理员与角色的关联表:ams_admin_role

角色与权限的关联表:ams_role_permission

Spring Security框架

关于Spring Security框架

Spring Security框架主要解决了认证与授权相关的问题。

认证信息:表示用户的身份的信息

认证:识别用户身份信息,具体可以表现为“登录”

授权:授予用户权限,使之可以进行某些访问,反之,如果用户没有得到相关授权,就不允许进行某些访问

Spring Security框架的依赖项

在Spring Boot项目中使用Spring Security时需要添加依赖项:

<!-- Spring Boot支持Spring Security的依赖项,用于处理认证与授权 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>

Spring Security的典型特征

当添加了`spring-boot-starter-security`依赖项,会自带一系列的自动配置,当启动项目后,相比此前的项目,会有以下变化:

所有的请求(包括根本不存在的)都是必须要登录的,如果未登录,会自动跳转到框架自带的登录页面

默认的用户名是user,密码是启用项目时在控制台提示的一串UUID值

登录时,如果在打开登录页面后重启过服务器端,应该刷新登录页面,否则,第1次输入并提交是无效的

当登录成功后,会自动跳转到此前尝试访问的URL

当登录成功后,可通过 /logout退出登录

默认不接受普通的POST请求,如果提交POST请求,会响应403(Forbidden)

具体原因参见后续的CSRF相关内容

关于Spring Security的配置

在项目的根包下创建SecurityConfiguration类,作为Spring Security的配置类,继承自WebSecurityConfigurerAdpater类,并重写void configure(HttpSecurity http)方法,在方法体中进行配置:

@Slf4j@Configurationpublic class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {// super.configure(http); // 不要保留调用父类同名方法的代码,不要保留!不要保留!不要保留!}}

关于默认的登录表单

在配置类的void configure(HttpSecurity http)方法中,在没有调用父级的同名方法时,默认是不启用登录表单的!

如果需要启用登录表单,需要在方法中自行调用http.formLogin(),例如:

@Overrideprotected void configure(HttpSecurity http) throws Exception {// 如果调用以下方法,当需要访问通过认证的资源,但是未通过认证时,将自动跳转到登录页面// 如果未调用以下方法,将响应403http.formLogin();// super.configure(http); // 不要保留调用父类同名方法的代码,不要保留!不要保留!不要保留!}

关于请求的访问控制

在配置类的void configure(HttpSecurity http)方法中,调用参数对象的authroizeRequests()方法可开启对请求进行授权,例如:

@Overrideprotected void configure(HttpSecurity http) throws Exception {// 白名单// 使用1个星号,表示通配此层级的任意资源,例如:/admins/*,可以匹配:/admins/add-new、/admins/delete// 但是,不可以匹配多个层级,例如:/admins/*,不可以匹配:/admins/9527/delete// 使用2个连续的星号,表示通配若干层级的任意资源,例如:/admins/*,可以匹配:/admins/add-new、/admins/9527/deleteString[] urls = {"/doc.html","/**/*.css","/**/*.js","/swagger-resources","/v2/api-docs",};// 基于请求的访问控制http.authorizeRequests() // 对请求进行授权.mvcMatchers(urls) // 匹配某些路径.permitAll() // 直接许可,即不需要认证即可访问.anyRequest() // 任意请求.authenticated(); // 要求通过认证的}

注意:以上对请求授权的配置是遵循“第一匹配原则”的!例如,假设存在以下配置:

http.authorizeRequests().mvcMatchers("/test").authenticated().mvcMatchers("/test").permitAll();

按照以上配置,/test是“需要通过认证才可以访问的”!

注意:在配置请求授权时,调用anyRequest()表示“任意请求”,即“所有请求”,由于以上代码将anyRequest()配置在偏后的位置,也可以理解为“除了以上配置过的请求以外的所有请求”!

注意:在开发实践中,应该将更加具体的URL或请求配置在靠前的位置,将使用了通配符的,或使用anyRequest()匹配的请求配置在靠后的位置。

使用临时的自定义的账号实现登录

在使用Spring Security框架时,可以自定义组件类,实现UserDetailsService接口,则Spring Security框架会基于此类的对象来处理认证!

在项目的根包下创建security.UserDetailsServiceImpl类,在类上添加@Service,实现UserDetailsService接口,重写接口中的方法:

@Slf4j@Servicepublic class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {return null;}}

当项目中存在UserDetailsService类型的组件对象时,尝试登录时,Spring Security框架会自动使用登录表单中的用户名来调用以上loadUserByUsername()方法,并且,得到此方法返回的UserDetails类型的结果,此结果中应该包含用户的相关信息,例如用户名、密码、账号状态等,接下来,Spring Security框架会自动使用登录表单中的密码与返回的`UserDetails`中的密码进行对比,并判断账号的状态,以此决定表单提交的登录信息是否可以通过认证。

所以,以上loadUserByUsername()`方法的实现中,只需要完成“根据用户名返回匹配的UserDetails对象”即可!例如:

@Slf4j@Servicepublic class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {// 假设允许登录的账号是:root / 123456if (!"root".equals(s)) {return null;}UserDetails userDetails = User.builder().username("root").password("123456").disabled(false) // 账号禁用.accountLocked(false) // 账号锁定.accountExpired(false) // 账号过期.credentialsExpired(false) // 凭证过期.authorities("这是一个临时使用的山寨权限") // 权限.build();return userDetails;}}

当项目中存在UserDetailsService类型的组件对象时,启用项目时控制台中将不再显示user账号的UUID密码,并且,user账号也不再可用!

注意:Spring Security框架在处理登录信息时,默认要求所有密码都是通过某种密码编码器处理过后的,如果使用的密码是明文的,必须明确的指出!例如,在配置类中通过@Bean方法配置NoOpPasswordEncoder,例如:

@Configurationpublic class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance();}}

使用数据库中的账号信息实现登录

首先,需要实现“根据用户名查询用户信息”的查询功能,需要执行的SQL语句大致是:

SELECT id, username, password, enable FROM ams_admin WHERE username=?

在pojo.vo包下创建AdminLoginInfoVO类:

package cn.tedu.csmall.passport.pojo.vo;import lombok.Data;import java.io.Serializable;/*** 管理员的登录信息的VO类** @author java@* @version 0.0.1*/@Datapublic class AdminLoginInfoVO implements Serializable {/*** 数据id*/private Long id;/*** 用户名*/private String username;/*** 密码(密文)*/private String password;/*** 是否启用,1=启用,0=未启用*/private Integer enable;}

在AdminMapper.java接口中添加抽象方法:

AdminLoginInfoVO getLoginInfoByUsername(String username);

在AdminMapper.xml中配置以上抽象方法映射的SQL语句:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); --><select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">SELECT<include refid="LoginInfoQueryFields"/>FROMams_adminWHEREusername=#{username}</select><sql id="LoginInfoQueryFields"><if test="true">id, username, password, enable</if></sql><resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"><id column="id" property="id"/><result column="username" property="username"/><result column="password" property="password"/><result column="enable" property="enable"/></resultMap>

在AdminMapperTests中编写并执行测试:

@Testvoid getLoginInfoByUsername() {String username = "root";Object queryResult = mapper.getLoginInfoByUsername(username);log.debug("根据用户名【{}】查询数据详情完成,查询结果:{}", username, queryResult);}

然后,调整UserDetailsServiceImpl中的实现:

@Autowiredprivate AdminMapper adminMapper;@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("xxx");AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);if (loginInfo == null) {return null;}UserDetails userDetails = User.builder().username(loginInfo.getUsername()).password(loginInfo.getPassword()) // 期望是密文.disabled(loginInfo.getEnable() == 0) // 账号禁用.accountLocked(false) // 账号锁定.accountExpired(false) // 账号过期.credentialsExpired(false) // 凭证过期.authorities("这是一个临时使用的山寨权限") // 权限.build();return userDetails;}

由于数据库中的测试数据的密码都是密文的,例如:

$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C

以上密文是通过BCrypt算法进行编码的结果!为了保证Spring Security能够正确的判断密码,需要将密码编码器改为BCrypt的密码编码器,例如:

@Beanpublic PasswordEncoder passwordEncoder() {// return NoOpPasswordEncoder.getInstance();return new BCryptPasswordEncoder();}

关于伪造的跨域攻击

伪造的跨域攻击:此类攻击原理是利用服务器端对客户端浏览器的“信任”来实现的!当在某个客户端的浏览器的第1个选项卡登录后,如果在第2个或其它选项卡中访问同一个服务器端,均会被视为“已登录”的状态!假设,你在第1个选项卡登录了你的网上银行,然后,在第2个选项卡中打开了某个坏人的网站,这个坏人的网站中隐藏了一个向银行发起转账的链接(例如把请求的链接设置为`<img>`标签的`src`属性值),网上银行收到第2个选项卡中发出的链接的请求,仍会认为这是一个正常的请求,会尝试执行转账操作!这种攻击方式就称之为“伪造的跨域攻击”。当然,实际情况是不可能实现网上银行转账的,但是,仍可能利用这样的机制实施某些攻击行为,例如窃取数据。

典型的防御手段:在“非前后端分离”的开发模式下,当服务器端生成表单时,会在表单中隐藏一个具有“唯一性”较强的“随机值”,例如UUID值,当客户端正常提交表单时,此值会随着表单一起提交到服务器端,服务器端会对比收到的此值是否为此前生成的值,以Spring Security的登录表单为例:

在“前后端分离”的项目中,由于服务器端不负责生成各表单页面,也就无法在表单中添加UUID值,则提交请求时,就不可能提交正确的UUID值,所以,这种防御机制在前后端分离的项目中并不适用!

在Spring Security的配置类中,在void configurer(HttpSecurity http)方法中,调用参数对象的csrf().disable()即可禁用默认的防御机制,例如:

@Overrideprotected void configure(HttpSecurity http) throws Exception {// 禁用“防止伪造的跨域攻击”的防御机制http.csrf().disable();// 暂不关心其它代码}

使用前后端分离的登录

Spring Security框架自带了登录页面和退出登录的页面,不是前后端分离的,则不可与自行开发的前端项目进行交互,如果需要改为前后端分离的模式,需要:

不再启用登录表单

使用控制器接收客户端提交的登录请求

需要自定义DTO类封装客户端提交的用户名、密码

使用Service组件实现登录的验证

在IAdminService中添加抽象方法,在AdminServiceImpl中实现

具体的登录验证,仍可由Spring Security框架来完成,仅需调用AuthenticationManager对象的authenticate()方法即可,则AuthenticationManager会自动基于用户名调用UserDetailsService接口对象的loadUserByUsername()方法,并得到返回的UserDetails对象,然后自动判断密码是否正确、账号状态是否有效等

可通过Spring Security的配置类中添加@Bean方法来配置AuthenticationManager

在SecurityConfiguration类中添加方法配置AuthenticationManager:

// 【注意】配置AuthenticationManager对象时// 不要使用authenticationManager()方法,如果使用此方法,在测试时可能导致死循环,从而内存溢出// 必须使用authenticationManagerBean()方法// @Bean// @Override// protected AuthenticationManager authenticationManager() throws Exception {//return super.authenticationManager();// }@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}

在项目的根包下创建pojo.dto.AdminLoginInfoDTO类:

@Datapublic class AdminLoginInfoDTO implements Serializable {/*** 用户名*/private String username;/*** 密码(原文)*/private String password;}

然后,在IAdminService中添加抽象方法:

/*** 管理员登录* @param adminLoginInfoDTO 封装了用户名、密码等相关信息的对象*/void login(AdminLoginInfoDTO adminLoginInfoDTO);

并在AdminServiceImpl中实现以上方法:

@Autowiredprivate AuthenticationManager authenticationManager;@Overridepublic void login(AdminLoginInfoDTO adminLoginInfoDTO) {log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());authenticationManager.authenticate(authentication);log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");}

在AdminController中添加处理登录请求的方法:

// http://localhost:9081/admins/login@PostMapping("/login")@ApiOperation("管理员登录")@ApiOperationSupport(order = 10)public JsonResult<Void> login(AdminLoginInfoDTO adminLoginInfoDTO) {log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginInfoDTO);adminService.login(adminLoginInfoDTO);return JsonResult.ok();}

在测试使用之前,还应该将以上登录的URL添加到“白名单”中,例如:

String[] urls = {"/doc.html","/**/*.css","/**/*.js","/swagger-resources","/v2/api-docs","/admins/login" // 管理员登录的URL};

测试访问时,如果用户名不存在,Spring Security框架将抛出异常:

org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation

如果密码错误,则是:

org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误

如果账号被禁用,则是:

org.springframework.security.authentication.DisabledException: 用户已失效

接下来,还应该在全局异常处理器中添加对以上3种异常的处理。

对于用户名错误、密码错误,在反馈到客户端的信息中,通常并不会明确的区分开来,而是直接提示“用户名或密码错误”的字样即可!

首先,在ServiceCode中添加对应的业务状态码:

/*** 错误:未通过认证,或未找到认证信息*/ERROR_UNAUTHORIZED(40100),/*** 错误:未通过认证,因为账号被禁用*/ERROR_UNAUTHORIZED_DISABLED(40101),

关于用户名错误和密码错误时的异常,其继承结构是:

AuthenticationException-- BadCredentialsException【密码错误】-- AuthenticationServiceException-- -- InternalAuthenticationServiceException【用户名错误】

并处理异常:

@ExceptionHandler({InternalAuthenticationServiceException.class,BadCredentialsException.class})public JsonResult<Void> handleAuthenticationException(AuthenticationException e) {log.warn("程序运行过程中出现AuthenticationException,将统一处理!");log.warn("异常类型:{}", e.getClass().getName());log.warn("异常信息:{}", e.getMessage());String message = "登录失败,用户名或密码错误!";return JsonResult.fail(ServiceCode.ERROR_UNAUTHORIZED, message);}@ExceptionHandlerpublic JsonResult<Void> handleDisabledException(DisabledException e) {log.warn("程序运行过程中出现DisabledException,将统一处理!");log.warn("异常信息:{}", e.getMessage());String message = "登录失败,账号已经被禁用!";return JsonResult.fail(ServiceCode.ERROR_UNAUTHORIZED_DISABLED, message);}

关于通过认证的标准

在Spring Security框架中,为每个客户端分配了一个SecurityContext,会根据在SecurityContext中是否存在认证信息来判断是否已经通过认证,即:

如果在SecurityContext中存在认证信息,则视为“已通过认证”

如果在SecurityContext中没有认证信息,则视为“未通过认证”

同时,SecurityContext默认是基于Session机制的,所以,也符合Session的相关特征,例如默认的有效期。

在项目中,可以通过SecurityContextHolder的静态方法getContext()方法来获取当前的SecurityContext对象,也可以通过SecurityContextHolder的静态方法clearContext()方法来清空SecurityContext中的信息。

所以,在AdminServiceImpl中处理登录时,当验证通过后,应该及时获取认证信息,并保存到SecurityContext中:

@Overridepublic void login(AdminLoginInfoDTO adminLoginInfoDTO) {Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());// 注意:需要获取验证登录后的返回结果Authentication authenticateResult= authenticationManager.authenticate(authentication);// 将返回的认证信息保存到SecurityContext中SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authenticateResult);}

关于未通过认证的拒绝访问

当未通过认证时,访问那些需要授权的资源(必须登录后才可以发起的请求),默认响应403错误!

需要在Spring Security的配置类中的void configurer(HttpSecurity http)方法进行处理:

// 处理未通过认证时导致的拒绝访问http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {response.setContentType("application/json; charset=utf-8");PrintWriter writer = response.getWriter();writer.println("{\n" +" \"state\": 40100,\n" +" \"message\": \"您当前未登录,请登录!\"\n" +"}");writer.close();}});

识别当事人

当通过登录验证后,在SecurityContext中就已经存入了认证信息(Authentication),在认证信息中还包含了当事人(Principal),后续,可以在任何需要识别当事人的场景中,获取此当事人信息!

在控制器中处理请求的方法的参数列表上,可以注入当事人类型的参数,并且需要在此参数上添加@AuthenticationPrincipal注解:

@GetMapping("")// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 注解// 当事人类型 ↓↓↓↓↓↓↓↓↓↓↓public JsonResult<List<AdminListItemVO>> list(@AuthenticationPrincipal UserDetails userDetails) {log.debug("开始处理【查询管理员列表】的请求,参数:无");log.debug("当事人信息:{}", userDetails);log.debug("当事人信息中的用户名:{}", userDetails.getUsername());List<AdminListItemVO> list = adminService.list();return JsonResult.ok(list);}

注意:当添加以上参数后,API文档框架会误以为此参数是需要由客户端提交的请求参数,在API文档的调试页面中将显示相对应的输入框(可能需要刷新),要求输入相关的参数,实际此参数是由Spring Security从SecurityContext中取出认证信息中的当事人来注入的,并不应该由客户端提交,所以,应该在此参数上添加@ApiIgnore注解,表示API文档应该忽略此参数:

@GetMapping("")// ↓↓↓↓↓↓↓↓↓↓ 注解public JsonResult<List<AdminListItemVO>> list(@ApiIgnore @AuthenticationPrincipal UserDetails userDetails) {log.debug("开始处理【查询管理员列表】的请求,参数:无");log.debug("当事人信息:{}", userDetails);log.debug("当事人信息中的用户名:{}", userDetails.getUsername());List<AdminListItemVO> list = adminService.list();return JsonResult.ok(list);}

由于Spring Security框架要求loadUserByUsername()返回UserDetails类型的对象,且框架提供的实现类User中并不完全包含开发实践时所需要的属性,例如ID等,则使用Spring Security已有的类型并不能满足编程需求!

可以自定义类,实现UserDetails接口,或者,继承自User类,然后,在自定义类中声明所需的各属性等,后续,在loadUserByUsername()中返回自定义类的对象,则验证登录通过时返回的认证信息中的当事人也是此对象,存入到SecurityContext中的认证信息也是同一个认证信息,所以在控制器的方法中注入的当事人也是此对象!

在项目的根包下创建security.AdminDetails类,继承自User类:

@Getter@ToString(callSuper = true)@EqualsAndHashCode(callSuper = true)public class AdminDetails extends User {private Long id;public AdminDetails(Long id, String username, String password, boolean enabled,Collection<? extends GrantedAuthority> authorities) {super(username, password, enabled,true, true, true, authorities);this.id = id;}}

然后,在UserDetailsService中,在loadUserByUsername()方法中返回以上自定义类的对象:

@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);if (loginInfo == null) {return null;}List<GrantedAuthority> authorities = new ArrayList<>();authorities.add(new SimpleGrantedAuthority("这是第一个临时使用的山寨权限"));authorities.add(new SimpleGrantedAuthority("这是第二个临时使用的山寨权限"));AdminDetails adminDetails = new AdminDetails(loginInfo.getId(),loginInfo.getUsername(),loginInfo.getPassword(),loginInfo.getEnable() == 1,authorities);return adminDetails;}

后续,在控制器类中处理请求的方法的参数列表中,就可以注入自定义类型的当事人:

@GetMapping("")public JsonResult<List<AdminListItemVO>> list(// ↓↓↓↓↓↓↓↓↓↓↓↓ 自定义类型的当事人@ApiIgnore @AuthenticationPrincipal AdminDetails adminDetails) {log.debug("开始处理【查询管理员列表】的请求,参数:无");log.debug("当事人信息:{}", adminDetails);log.debug("当事人信息中的ID:{}", adminDetails.getId()); // 获取扩展的ID属性的值log.debug("当事人信息中的用户名:{}", adminDetails.getUsername());List<AdminListItemVO> list = adminService.list();return JsonResult.ok(list);}

-03-07 14:41:58.019 DEBUG 29640 --- [nio-9081-exec-1] c.t.c.p.service.impl.AdminServiceImpl : 认证结果:UsernamePasswordAuthenticationToken [Principal=AdminDetails(super=cn.tedu.csmall.passport.security.AdminDetails [Username=liucangsong, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[这是第一个临时使用的山寨权限, 这是第二个临时使用的山寨权限]], id=3), Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[这是第一个临时使用的山寨权限, 这是第二个临时使用的山寨权限]]

授权访问

首先,需要调整原有的“根据用户名查询管理员的登录信息”的查询功能,将管理员对应的权限列表查询出来,需要执行的SQL语句大致是:

-- 管理员表 <===> 管理员与角色的关联表 <===> 角色表 <===> 角色与权限的关联表 <===> 权限表SELECTams_admin.id,ams_admin.username,ams_admin.password,ams_admin.enable,ams_permission.valueFROMams_adminLEFT JOIN ams_admin_role ON ams_admin.id = ams_admin_role.admin_idLEFT JOIN ams_role_permission ON ams_admin_role.role_id = ams_role_permission.role_idLEFT JOIN ams_permission ON ams_role_permission.permission_id = ams_permission.idWHEREusername='root';

在AdminLoginInfoVO中,添加属性,以表示管理员的权限列表:

@Datapublic class AdminLoginInfoVO implements Serializable {// 原有其它属性/*** 权限列表*/private List<String> permissions;}

然后,调整AdminMapper.xml中的相关配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); --><select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">SELECT<include refid="LoginInfoQueryFields"/>FROMams_adminLEFT JOIN ams_admin_role ON ams_admin.id = ams_admin_role.admin_idLEFT JOIN ams_role_permission ON ams_admin_role.role_id = ams_role_permission.role_idLEFT JOIN ams_permission ON ams_role_permission.permission_id = ams_permission.idWHEREusername=#{username}</select><sql id="LoginInfoQueryFields"><if test="true">ams_admin.id,ams_admin.username,ams_admin.password,ams_admin.enable,ams_permission.value</if></sql><!-- collection标签:用于配置1对多的查询,也可理解为配置List属性对应的值如何封装 --><!-- collection标签的property属性:与id或result标签的property属性相同 --><!-- collection标签的ofType属性:List中的元素类型 --><!-- collection标签的子级:如何创建List中的元素对象 --><resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"><id column="id" property="id"/><result column="username" property="username"/><result column="password" property="password"/><result column="enable" property="enable"/><collection property="permissions" ofType="java.lang.String"><constructor><arg column="value"/></constructor></collection></resultMap>

完成后,可通过原有的测试进行检验,执行结果例如:

-03-07 15:51:12.352 DEBUG 30784 --- [ main] c.t.c.passport.mapper.AdminMapperTests : 根据用户名【root】查询数据详情完成,查询结果:AdminLoginInfoVO(id=1, username=root, password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C, enable=1, permissions=[/ams/admin/read, /ams/admin/add-new, /ams/admin/delete, /ams/admin/update, /pms/product/read, /pms/product/add-new, /pms/product/delete, /pms/product/update, /pms/brand/read, /pms/brand/add-new, /pms/brand/delete, /pms/brand/update, /pms/category/read, /pms/category/add-new, /pms/category/delete, /pms/category/update, /pms/picture/read, /pms/picture/add-new, /pms/picture/delete, /pms/picture/update, /pms/album/read, /pms/album/add-new, /pms/album/delete, /pms/album/update])

然后,调用UserDetailsServiceImpl中的实现:

@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);log.debug("从数据库中根据用户名【{}】查询登录信息,结果:{}", s, loginInfo);if (loginInfo == null) {return null;}// ========== 存入真实的权限数据 ==========List<GrantedAuthority> authorities = new ArrayList<>();List<String> permissions = loginInfo.getPermissions();for (String permission : permissions) {authorities.add(new SimpleGrantedAuthority(permission));}AdminDetails adminDetails = new AdminDetails(loginInfo.getId(),loginInfo.getUsername(),loginInfo.getPassword(),loginInfo.getEnable() == 1,authorities);log.debug("即将向Spring Security返回UserDetails类型的对象:{}", adminDetails);return adminDetails;}

至此,当任何管理员登录后,在SecurityContext中的认证信息是包含此管理员的真实权限的!

提示:UserDetailsService接口方法loadUserByUsername()返回的UserDetails是AuthenticationManager的authenticate()返回的认证信息中的当事人,而此认证信息会被存入到SecurityContext中!

接下来,就可以实现使用Spring Security验证已登录的管理员的权限!

需要在配置类上添加@EnableGlobalMethedSecurity(prePostEnabled = true),以开启在方法之前或之后的权限检查,例如:

@Slf4j@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfiguration extends WebSecurityConfigurerAdapter {}

然后,在需要检查权限的方法(任意方法,不一定是控制器中处理请求的方法)上使用@PreAuthorize注解进行配置,以实现在执行方法之前的权限检查,例如:

@PreAuthorize("hasAuthority('/ams/admin/delete')") // 检查权限@PostMapping("/{id:[0-9]+}/delete")public JsonResult<Void> delete(@PathVariable Long id) {log.debug("开始处理【根据ID删除管理员】的请求,参数:{}", id);adminService.delete(id);return JsonResult.ok();}

提示:还可以使用@PostAuthorize注解配置执行方法之后的权限检查。

当不具备相关权限却尝试调用方法时,会出现异常:

org.springframework.security.access.AccessDeniedException: 不允许访问

所以,还应该在全局异常处理器中添加处理以上异常的方法:

@ExceptionHandlerpublic JsonResult<Void> handleAccessDeniedException(AccessDeniedException e) {log.warn("程序运行过程中出现AccessDeniedException,将统一处理!");log.warn("异常信息:{}", e.getMessage());String message = "拒绝访问,您当前登录的账号无此操作权限!";return JsonResult.fail(ServiceCode.ERROR_FORBIDDEN, message);}

-03-07 16:15:18.935 DEBUG 30080 --- [nio-9081-exec-1] c.t.c.p.security.UserDetailsServiceImpl : 即将向Spring Security返回UserDetails类型的对象:AdminDetails(super=cn.tedu.csmall.passport.security.AdminDetails [Username=liucangsong, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[/pms/product/add-new, /pms/product/delete, /pms/product/read, /pms/product/update]], id=3)

-03-07 16:15:19.009 DEBUG 30080 --- [nio-9081-exec-1] c.t.c.p.service.impl.AdminServiceImpl : 认证结果:UsernamePasswordAuthenticationToken [Principal=AdminDetails(super=cn.tedu.csmall.passport.security.AdminDetails [Username=liucangsong, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[/pms/product/add-new, /pms/product/delete, /pms/product/read, /pms/product/update]], id=3), Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[/pms/product/add-new, /pms/product/delete, /pms/product/read, /pms/product/update]]

关于Session

服务器端的应用程序通常是基于HTTP协议的,HTTP协议本身是一种"无状态"协议,所以,它并不能保存客户端的状态,例如,无法识别客户端的身份,所以,即使同一个客户端多次访问同一个服务器端,服务器并不能识别出它就是此前来访的客户端!

在开发实践中,大多是是需要能够识别客户端身份的,通常可以使用Session机制来解决!

当某个客户端首次访问某个服务器时,将直接发起请求,当服务器收到此请求时,会在响应时返回一个Session ID值(本质上是一个UUID值),当客户端收到Session ID后,后续的访问都会自动携带此Session ID到服务器端,则服务器端可以根据这个Session ID值来识别客户端的身份。

在服务区端,使用了K-V结构的数据表示Session,客户端携带的Session ID就是K-V结构中的Key,所以,每个客户端都可以访问到不同的Value,即每个客户端对应的Session数据。

Session是存储在服务器端的内存中的数据,而内存资源时相对有限的资源,存储空间相对较小,必然存在清除Session机制,默认的清除机制是"超时自动清除",即某个客户端最后一次提交请求之后,在多长时间之内没有再次提交数据,服务器端就会清除此客户端的对应的Session数据!至于过多久清除Session,没有明确要求,大多软件的默认时间是15~30分钟,但是,也可以设置为更短或更长的时间。

基于Session的特点,通常存入到Session的数据大多是:

用户身份的标识,例如已登录的用户的ID

访问频率较高的数据,例如已登录的用户的用户名

不易于/不便于使用其它存储机制的数据,例如验证码

同时Session还存在一些缺点:

不适合存储大量的数据

可以通过规范的开发避免此问题

不易于应用到集群或分布式系统中

可以通过共享Session解决此问题

不可以长时间存储

无解

关于Token

Token:令牌,票据

Token机制是用于解决服务器端识别客户端身份的。

在使用Token机制时,当客户端首次向服务器提交请求时,或提交登录的请求时,客户端是直接将请求发送到服务器端的,并不做特殊处理,而服务器端会按需处理请求(例如客户端提交的是登录请求,则处理登录),并且将客户端的身份数据生成一个Token,并将此Token响应到客户端去,后续,客户端需要携带此Token提交各种请求,服务器端也会根据此Token数据来识别客户端的身份。

与Session不同,Token是由服务器端的程序(自行编写的)生成的数据,是一段有意义的数据,相比之下Session机制中的Session ID是一个UUID值,仅保证唯一性,数据本身是没有意义的!Token不需要在服务器端存在匹配的数据,因为自身就是数据!

在处理过程中,服务端只需要检查Token,并从Token中解析出客户端身份相关的数据即可,在服务器端的内存中并不需要保存Token的数据,所以,Token是可以设置较长甚至很长的有效期的,不会消耗服务器端用于存储数据的内存资源。

同时,Token天生适用于集群或分布式系统,只需要各服务器具有相同的检查Token和解析Token即可

关于JWT

JWT:JSON Web Token

JWT的官网:https://jwt.io/

每个JWT数据都是由3大部分组成的:

Header:声明算法与Token类型

Payload:数据

Verify Signature:验证签名

关于JWT编程的工具包:https://jwt.io/libraries?language=Java

例如,在项目中添加JJWT的依赖项:

<!-- JJWT(Java JWT) --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency>

接下来,就可以在项目中尝试生成、解析JWT数据,例如:

package cn.tedu.csmall.passport;import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jws;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.SignatureAlgorithm;import org.junit.jupiter.api.Test;import java.util.Date;import java.util.HashMap;import java.util.Map;public class JwtTests {String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";@Testvoid generate() {Map<String, Object> claims = new HashMap<>();claims.put("id", 9527);claims.put("username", "spring");String jwt = Jwts.builder()// Header:声明算法与Token类型.setHeaderParam("alg", "HS256").setHeaderParam("typ", "JWT")// Payload:数据,具体表现为Claims.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 1000))// Verify Signature:验证签名.signWith(SignatureAlgorithm.HS256, secretKey).compact();System.out.println(jwt);}@Testvoid parse() {String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjc4MjQzNDYwLCJ1c2VybmFtZSI6InNwcmluZyJ9.HZni3OQYS1YwTEpBoNPPz222UrgCcdD1j7nBDgoZxzs";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object id = claims.get("id");Object username = claims.get("username");System.out.println("id = " + id);System.out.println("username = " + username);}}

如果尝试解析的JWT已过期,会出现错误:

io.jsonwebtoken.ExpiredJwtException: JWT expired at -03-08T10:30:16Z. Current time: -03-08T10:41:32Z, a difference of 676763 milliseconds. Allowed clock skew: 0 milliseconds.

如果尝试解析的JWT数据格式有误,会出现错误:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":952name":"spring"}

如果尝试解析的JWT数据签名有误,会出现错误:

io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

注意:即使不知道secretKey,其实也可以解析出JWT数据中的内容,例如将JWT数据得到的到JWT的官网即可解析出内容,所以,不要在JWT中存入敏感数据!另外,即使在JWT官网或者使用其它API可以解读出JWT中的数据,也会提示"无法验证签名"的字样,包括解析失败的异常信息也会提示"不要信任此次的解析结果"。

在项目中使用JWT识别用户的身份

核心流程

在项目中使用JWT识别用户的身份,大致需要:

当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端

当通过验证后,不再需要(没有必要)将用户的认证信息存入到SecurityContext中

当用户尝试执行需要通过认证的操作时,用户应该自主携带JWT,并且,服务器端应该尝试解析此JWT,从而验证JWT的真伪,并识别用户的身份,如果一切无误,再将用户的认证信息存入到SecurityContext中

登录成功后响应JWT

当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端!

当通过验证后,不再将用户的认证信息存入到SecurityContext中,则在AdminServiceImpl的login()方法中调整:

// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext,则注释或删除以下2行代码// SecurityContext securityContext = SecurityContextHolder.getContext();// securityContext.setAuthentication(authenticateResult);

在IAdminService中,需要将login()方法的返回值类型改为String,表示登录成功后将返回JWT数据,例如:

/*** 管理员登录* @param adminLoginInfoDTO 封装了用户名、密码等相关信息的对象* @return 此管理员登录后得到的JWT数据*/String login(AdminLoginInfoDTO adminLoginInfoDTO);

并且,调整AdminServiceImpl中的login()的声明与实现:

@Overridepublic String login(AdminLoginInfoDTO adminLoginInfoDTO) {log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());Authentication authenticateResult= authenticationManager.authenticate(authentication);log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");log.debug("认证结果:{}", authenticateResult);log.debug("认证结果中的当事人:{}", authenticateResult.getPrincipal());// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext// SecurityContext securityContext = SecurityContextHolder.getContext();// securityContext.setAuthentication(authenticateResult);// 需要存入到JWT中的数据AdminDetails adminDetails = (AdminDetails) authenticateResult.getPrincipal();Map<String, Object> claims = new HashMap<>();claims.put("id", adminDetails.getId());claims.put("username", adminDetails.getUsername());// 权限待定// 生成JWT,以下代码是相对固定的String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";String jwt = Jwts.builder()// Header:声明算法与Token类型.setHeaderParam("alg", "HS256").setHeaderParam("typ", "JWT")// Payload:数据,具体表现为Claims.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000))// Verify Signature:验证签名.signWith(SignatureAlgorithm.HS256, secretKey).compact();log.debug("生成了JWT数据:{}", jwt);return jwt;}

然后,还需要调整AdminController的login()方法,在调用Service的login()方法时获取返回的JWT,并响应到客户端,例如:

@PostMapping("/login")public JsonResult<String> login(AdminLoginInfoDTO adminLoginInfoDTO) {log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginInfoDTO);String jwt = adminService.login(adminLoginInfoDTO);return JsonResult.ok(jwt);}

解析客户携带的JWT

客户端提交请求(无论是什么请求),都可能携带了JWT,在服务器端,处理多种不同的请求时都可能需要获取并尝试解析JWT,则应该使用过滤器(Filter)组件进行处理!

提示:过滤器(Filter)是java服务器端应用程序的核心组件之一,它是最早接收到请求的组件!过滤器可以选择对此请求进行"阻止"或"放行"!同一个项目中,允许存在若干个过滤器,形成"过滤器链"(FilterChain),任何一个请求,仅当过滤器链上的每个过滤器都选择"放行"才可以被控制器或其他组件进行处理!

在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter类,并在类上添加@Component注解,例如:

package cn.tedu.csmall.passport.filter;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.*;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;public class JwtAuthorizationFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {}}

然后,在过滤器的方法中接收JWT数据:

@Slf4j@Componentpublic class JwtAuthorizationFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {log.debug("JWT过滤器开始执行……");// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中String jwt = request.getHeader("Authorization");log.debug("客户端携带的JWT:{}", jwt);// 放行filterChain.doFilter(request, response);}}

然后,还需要在SecurityConfiguration中将此过滤器注册到Spring Security框架的过滤器链中:

在API文档中,通过“全局参数设置”来配置请求头中的JWT数据:

注意:在进行以上配置时,参数名称Authorization是严格区分大小写的,也不允许有多余的空格!

接下来,任何新打开的调试页面中都可以看到请求头中携带的数据:

package cn.tedu.csmall.passport.filter;import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwts;import lombok.extern.slf4j.Slf4j;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.context.SecurityContext;import org.springframework.security.core.context.SecurityContextHolder;import org.ponent;import org.springframework.util.StringUtils;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.util.ArrayList;import java.util.List;/*** <p>JWT过滤器</p>** <p>此过滤器的主要作用</p>* <ul>*<li>接收客户端提交的请求中的JWT</li>*<li>尝试解析客户端提交的请求中的有效JWT</li>*<li>将解析成功得到的数据创建为Authentication对象,并存入到SecurityContext中</li>* </ul>*/@Slf4j@Componentpublic class JwtAuthorizationFilter extends OncePerRequestFilter {/*** JWT的最小长度值*/public static final int JWT_MIN_LENGTH = 113;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {log.debug("JWT过滤器开始执行……");// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中String jwt = request.getHeader("Authorization");log.debug("客户端携带的JWT:{}", jwt);// 判断客户端是否携带了有效的JWTif (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {// 如果JWT无效,直接放行log.debug("客户端没有携带有效的JWT,将放行,由后续的过滤器等组件继续处理此请求……");filterChain.doFilter(request, response);return;}// 尝试解析JWTString secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object id = claims.get("id");Object username = claims.get("username");log.debug("解析JWT结束,id={},username={}", id, username);// 临时处理认证信息中的权限List<GrantedAuthority> authorities = new ArrayList<>();authorities.add(new SimpleGrantedAuthority("这是一个山寨的权限!"));// 创建Authentication对象Object principal = username;Object credentials = null;Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);// 将Authentication对象存入到SecurityContext中SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authentication);// 放行filterChain.doFilter(request, response);}}

关于当事人

通常,当事人信息中应该至少包含用户的ID和用户名,而认证信息(Authentication)中的当事人(Principal)的类型被设计为Object,所以,你可以使用任何类型的数据作为当事人!则可以自定义类封装用户的ID和用户名!

在项目的根包下创建security.LoginPrincipal类,例如:

@Datapublic class LoginPrincipal implements Serializable {/*** 当事人的ID*/private Long id;/*** 当事人的用户名*/private String username;}

在解析JWT时,将解析结果处理为期望的类型,例如:

// 尝试解析JWTString secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Long id = claims.get("id", Long.class); // 期望的类型String username = claims.get("username", String.class); // 期望的类型log.debug("解析JWT结束,id={},username={}", id, username);

然后,基于解析结果创建当事人对象:

// 创建当事人对象,用于存入到Authentication对象中LoginPrincipal loginPrincipal = new LoginPrincipal();loginPrincipal.setId(id);loginPrincipal.setUsername(username);

然后,将当事人对象用于创建认证信息对象:

Object principal = loginPrincipal; // 当事人对象Object credentials = null;Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);

后续,在控制器类中处理请求的方法中,当需要当事人数据时,注入LoginPrincipal类型的参数即可:

public JsonResult<List<AdminListItemVO>> list(@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {log.debug("开始处理【查询管理员列表】的请求,参数:无");log.debug("当事人信息:{}", loginPrincipal);log.debug("当事人信息中的ID:{}", loginPrincipal.getId());log.debug("当事人信息中的用户名:{}", loginPrincipal.getUsername());List<AdminListItemVO> list = adminService.list();return JsonResult.ok(list);}

处理权限

在项目中添加fastjson依赖项:

<!-- fastjson:实现对象与JSON的相互转换 --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.75</version></dependency>

在AdminServiceImpl中的login()方法,当验证登录通过,将此管理员的权限列表转换为JSON格式的字符串,然后再存入到JWT中,例如:

则任何管理员成功登录后,得到的JWT中都将包含权限列表的信息!

在JwtAuthorizationFilter中,解析JWT时,可以从中获取到此前存入的权限列表的JSON字符串,将此字符串反序列化为原本的类型,即ArrayList<SimpleGrantedAuthority>类型,并将此对象存入到认证信息中,例如:

至此,可以继续使用Spring Security检查各请求上配置的权限!

关于清除SecurityContext

因为Spring Security是根据SecurityContext中的认证信息来识别用户的身份的,而SecurityContext本身是基于Session机制的,当携带JWT成功访问后(在SecurityContext中已经存入了认证信息),在后续的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!

可以在JWT过滤器刚刚开始执行时,就直接清空SecurityContext,即:

// 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】SecurityContextHolder.clearContext();

注意:Spring Security本身使用了ThreadLocal处理SecurityContext,所以,以上的清除做法只对当前线程有效,如果将以上代码放在doFilter()之后,并不能解决问题!

或者,在Spring Security的配置类中的void configurer(HttpSecurity http)方法中,配置创建Session的策略为“从不使用Session”,即:

// 配置Spring Security创建Session的策略:STATELESS=从不使用Session,NEVER=不主动创建Sessionhttp.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

关于使用配置文件

生成和解析JWT使用的secretKey应该使用配置文件进行配置,例如,在application-dev.yml中添加配置:

secretKey: gfd89uiKa89J043tAFrflkji9432kjfdsajm

然后,在AdminServiceImpl和JwtAuthorizationFilter均不再使用原本的secretKey局部变量(删除原有代码),改为通过@Value注解读取以上配置文件中的配置值:

@Value("${secretKey}")private String secretKey;

需要注意:所有自定义配置推荐使用公司名称、项目名称等不容易冲突的名称作为自定义配置的属性名前缀,并且,属性名不必太过于简短,更重要的是不能冲突,且清晰的表达意思!

关于JWT的配置,secretKey和有效时长都应该写到配置文件中,例如:

# 当前项目的自定义配置csmall:# JWT配置jwt:# 生成和解析JWT的secretKey,注意:此值应该是不易被预测的,且需要保密secret-key: gfd89uiKa89J043tAFrflkji9432kjfdsajm# JWT的有效时长,以分钟为单位,表现用户成功登录后,登录信息的有效时长duration-in-minute: 14400

然后,在项目中通过@Value注解读取以上配置并应用,例如:

@Value("${csmall.jwt.secret-key}")private String secretKey;@Value("${csmall.jwt.duration-in-minute}")private Long durationInMinute;

注意:以上配置的JWT有效时长是以分钟为单位,读取到值后,应该自行运算,得到以毫秒为单位的时间,以匹配Date类的使用:

关于复杂请求的跨域问题

当客户端向服务器端提交请求时,如果在请求头中定义了特殊属性(例如Authorization属性),则此请求就会被视为“复杂请求”,对于复杂请求,各浏览器默认情况下需要执行“预检(PreFlight)”。

预检时,会向目标URL提交OPTIONS方式的请求,如果此请求被允许,则可以正常提交原定的请求,如果此请求被拒绝,则出错!

在基于Spring Security框架的服务器端应用程序中,要解决复杂请求的跨域访问问题,可选的解决方案有:

在Spring Security的配置类的configurer(HttpSecurity http)方法中添加以下代码:

http.cors();

当添加以上配置后,会注册Spring Security自带的CorsFilter,此过滤器会放行OPTIONS类型的请求

在Spring Security的配置类的configurer(HttpSecurity http)方法中配置授权访问时,将所有OPTIONS类型的请求全部放行,例如:

提示:浏览器对复杂请求的预检是支持缓存的,也就是说,如果对某个URL提交的是复杂请求,当第1次请求通过后,后续的请求不再执行预检。

添加管理员时确定角色

添加管理员时,必须为新管理员分配至少1种角色,否则,新添加的管理员将无法对应任何权限,在后台管理项目中,添加这样的管理员是没有意义的!

要实现添加管理员时分配角色,在服务器端需要:

在Mapper层实现查询角色列表

在Service层实现查询角色列表

在Controller层实现查询角色列表

在原有的新增管理员的DTO中添加新的属性,表示若干个角色(Long[] roleIds)

在新增管理员的业务中,补充向“管理员与角色的关联表”中插入数据

Spring Security处理登录的流程

基于Spring Security与JWT实现单点登录

SSO(Single Sign On):单点登录,表现为在集群或分布式系统中,客户端只需要在某1个服务上登录成功,后续访问其它服务器都可以被识别身份。

使用JWT来表示用户的身份信息,本身就是支持单点登录的,因为各服务器端只需要有同样的解析JWT的程序即可!

当在csmall-passport中完成认证与权限后,可以将部分代码复制到csmall-product中,使得csmall-product中的许多请求也是需要通过认证才允许访问的,并且进行访问权限的控制!需要复制并调整的代码文件有:

复制依赖项:spring-boot-starter-security / jjwt / fastjson

复制配置文件中的自定义属性:csmall.jwt.secret-key / csmall.jwt.duration-in-minute

复制LoginPrincipal

复制ServiceCode,覆盖csmall-product中原有的文件

复制GlobalExceptionHandler,覆盖csmall-product中原有的文件

复制JwtAuthorizationFilter

复制Spring Security配置类

删除PasswordEncoder的@Bean方法

删除AuthenticationManager的@Bean方法

删除URL白名单中“管理员登录”的地址

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。