学习记录之Spring Security框架(续)
解析JWT时,可能会出现一些异常,例如:
- 当JWT数据过期时:
当生成和解析使用的密钥不一致时,或JWT数据的最后一部分被恶意篡改时:
当JWT数据的第1部分被恶意篡改时:
在项目中使用JWT
在项目中使用JWT时,通常需要关注的问题:
- 什么时候生成JWT:通常是登录成功之后,将生成JWT,且会将JWT响应到客户端
- 客户端什么时候携带JWT来访问服务器端:服务器端不关心
- 什么时候检查JWT:
向客户端响应JWT
当需要向客户端响应JWT时,需要:
- 在`AdminServiceImpl`的`login()`中,获取`authenticate()`返回的结果,将此结果转换成`User`类型,即可从此`User`类型中获取当初在`UserDetailsService`中存入的数据,然后,将必要的部分取出(暂时为`username`),将其生成为JWT数据(参考测试类,暂不考虑封装工具类)
- 在`IAdminService` 接口中将`login()`的返回值改为`String`
- 在`AdminServiceImpl`类中也将`login()`的返回值改为`String`,并返回JWT数据
- 在`AdminController`处理登录的方法中,调用Service组件的方法时获取返回值,并将此返回值封装到响应结果中
关于`AdminServiceImpl`中的实现代码:
关于客户端携带JWT数据
当客户端尝试访问需要认证才能请求的资源时,客户端应该携带JWT数据,而服务器端应该对JWT数据进行获取、检查、解析等处理。
当客户端携带JWT时,通常会将JWT数据放在请求头(Request Header)中的`Authorization`属性中,并且,通常,服务器端的程序都会设计为从请求头中的`Authorization`属性中获取JWT数据。
服务器端检查JWT
由于许多不同的请求都需要检查JWT,所以,不会在控制器中处理JWT!
通常,应该在过滤器组件中检查JWT!
- 过滤器是Java服务器端程序(无论你使用什么框架)中最早接收到客户端请求的组件,且所有请求都会经过过滤器才会执行到控制器
需自定义过滤器类:
并在配置类中添加配置:
完整编码流程:基于Security+JWT的管理员登录
- 相关依赖:`spring-boot-starter-security`、`jjwt`
- 创建管理员登录的VO类,例如`AdminLoginVO`
- 在`AdminMapper`接口和`AdminMapper.xml`文件中实现:根据用户名查询管理员信息,应该至少包括:用户名、密码、权限
- 自定义类,实现`UserDetailsService`接口,重写`loadUserByUsername()`方法,在此类中通过`AdminMapper`的查询找到对应的管理员信息,并封装到`UserDetails`类型的对象中返回
- 创建Security配置类,继承自`WebSecurityConfigurerAdapter`类,在此类中使用`@Bean`方法得到`AuthenticationManager`对象,使用`@Bean`方法得到`BCryptPasswordEncoder`对象
- 在`IAdminService`接口中添加登录的抽象方法,并在`AdminServiceImpl`中重写此方法,在方法体中,调用`AuthenticationManager`的`authenticate()`执行认证,如果认证通过,应该生成JWT数据并返回,此JWT数据中应该包含用户名及必要信息
- 在`AdminController`中处理登录请求,并通过调用`IAdminService`类型的组件来实现,将调用得到的JWT响应到客户端去
完整编码流程:登录后的访问
- 在Security的配置类中,指定一些白名单,这些是不需要登录就可以直接访问的,其它请求路径都必须登录后才可以访问,需要注意:登录、注册等请求路径必须在白名单,否则不合理
- 创建JWT过滤器,在此过滤器中:
- 清除Security的上下文
- 从请求头中获取JWT
- 对JWT数据进行基本判断(是否有值),如果没有有效值,直接放行
- 如果获取到有效的JWT,则解析,得到用户信息,将用户信息存入到上下文中
- 在Security的配置类中,添加以上过滤器,将其添加在`UsernamePasswordAuthenticationFilter`之前
关于CORS
CORS:跨域的异步访问,默认情况下,是不允许的。
在使用Spring MVC框架时,需要允许跨域访问时,可以自定义配置类,实现`WebMvcConfigure`接口,重写其中的`addCorsMappings()`方法:
当项目中进一步使用了Spring Security框架后,当客户端提交复杂请求(自定义了请求头中非常规属性,例如添加了`Authorization`属性)时,还需要在Spring Security的配置类允许复杂请求的跨域访问,解决方案可以是:
或者:
之所以需要进行这样的处理,是因为复杂请求本身有预检(PreFlight)机制,在提交请求时,客户端会自动先提交`OPTIONS`类型的请求,此时服务器端可能是不通过的,则会出现`403`错误,并且,实质尝试提交的请求(例如`GET`、`POST`)中复杂请求头部信息不会被提交。在浏览器端,一旦成功的提交了复杂请求,则后续不会自动提交`OPTIONS`请求执行预检。
实现授权访问
实现授权访问的步骤:
- 当用户尝试登录时,应该根据用户名从数据库中查询出此管理员的权限信息
- 在`UserDetailsServiceImpl`中,(当登录认证时,Spring Security框架会自动调用此类中的`loadUserByUesrname()`方法),根据用户名查询到有效管理员信息后,向`UserDetails`中存入权限信息
- 将`List<String>`格式的权限集合转换成`String...`格式即可,例如:
- 在`AdminServiceImpl`的`login()`中,认证成功后,从返回的`Authentication`中取出权限信息,并其生成到JWT中
- 为保证后续能从JWT中取出权限且还原成正常的格式,应该将权限列表(`Collection<? extend GrandtedAuthority>`)转换成JSON格式的字符串再写入
- 在`JwtAuthorizationFilter`中,从JWT中解析出权限,并存入到Security的上下文中
- 从JWT中解析出的权限是JSON格式的字符串,需还原成`Collection<? extend GrandtedAuthority>`类型才可以存入到Security的上下文中,可以还原成`List<SimpleGrantedAuthority>`
- 在Security的配置类`SecurityConfiguration`上添加注解`@EnableGlobalMethodSecurity(prePostEnabled = true)`以开启全局的授权访问检查
- 此配置是一次性的配置
- 在控制器中,在处理请求的方法上,使用`@PreAuthorize`注解,配置其中的`hasAuthority`属性,即可要求此请求必须具有某种权限
- 例如:`@PreAuthorize("hasAuthority('/ams/admin/read')")`
根据用户名查询管理员的权限
首先,在`AdminLoginVO`中添加必要的属性:
然后,在`AdminMapper.xml`中配置查询:
关于JWT过滤器的处理细节
解析JWT是可能失败的,例如JWT数据过期、签名错误、数据非法等,这些错误都应该被处理,否则,就会存在异常未处理的情况,最终将导致500错误!
关于以上可能的错误,应该大致分为3类,一类是JWT数据过期,一类是JWT数据被恶意篡改,再另外还有可能是其它的错误。
首先,先在`ServiceCode`中添加新的业务状态码,对应一些错误:
然后,需要在JWT过滤器中,自行使用`try...catch`来捕获并处理异常!
在登录的用户身份标识中添加自定义信息
Spring Security框架中并没有使用、封装用户的ID等相关信息,如果使用过程中,需要自行封装更多的信息,并添加到用户身份标识中,则需要:
- 自定义类实现`UserDetails`接口
- 或,自定义类继承`User`类
并且,在自定义类中添加所需的属性,例如ID,然后,在`UserDetailsService`的实现类中,在`loadUserByUsername()`方法返回自定义类的对象。
所以,创建`AdminDetails`类:
在`UserDetailsServiceImpl`中,需要返回时:
接下来,在`AdminServiceImpl`的`login()`方法中,通过`AuthenticationManager`的`authenticate()`执行认证且通过认证的返回结果就是以上`AdminDetails`对象,所以,可以从中获取管理员的id,并用于生成JWT数据,则用户登录成功后得到的JWT数据中将包含Id信息。
后续,客户端提交请求时,携带的JWT也是包含Id信息的,可以在`JwtAuthenticationFilter`中解析得到此Id,最终,此Id值应该封装到Security的上下文中,则可以利用`UsernamePasswordAuthenticationToken`类的`principal`属性(`Object`类型),所以,自定义类,用于封装后续可能需要使用到的管理员信息:
然后,在过滤器,将其存入:
至此,当客户端携带JWT访问服务器端时,服务器端的Security的上下文中就包含了管理员的id、用户名、权限,其中,权限不需要自行使用,都是Security框架自动判断(你只需要在控制器处理请求的方法上配置`@PreAuthorize`注解即可),当需要获取管理员的id、用户名时,可以在控制器处理请求的方法的参数列表中添加`Authentication`即可,此参数就是Security上下文中的认证信息(过滤器中存入的对象),例如:
从`Authentication`中获取`LoginPrincipal`比较麻烦,还需要自行获取、转换类型,可以改为声明`LoginPricipal`参数(在过滤器中封装到`UsernamePasswordAuthenticationToken`的`pricipal`属性中的对象),然后,在此参数前添加`@AuthenticationPrincipal`注解,即可直接使用:
Spring Security框架的相关概念
**Authorization**
认证,在项目中,它主要表现为携带JWT的请求头的属性名,是建议使用的属性名。
**Authority**
权限,通常表现为一些字符串,这些字符串应该具有唯一、易于阅读的特性,框架会根据登录后的用户信息和控制器中配置的权限进行检查,以判断某用户是否具有执行此操作的权力。
**Principal**
当事人,是`Authentication`中的部分属性,以`UsernamePasswordAuthenticationToken`为例,它当中就包括了Principal、Credentials、Authorities这3大部分,在项目中,如果`Authentication`是用于执行认证,则此Principal就是用户名,如果`Authentication`是用户认证后的信息,则可以包含其它意义,例如ID、用户名等。
**Token**
票据、令牌,指的是携带了一部分有意义的数据的信息。
**UserDetails**
用户详情,是用于执行认证过程中,封装用户的信息,例如,在`UserDetailsService`接口的实现类中,在`loadUserByUsername()`方法中就应该返回此类型的对象,则Spring Security会自动调用此方法来获取`UserDetails`类型的结果,此结果中应该包含密码,且Spring Security会自动调用`PasswordEncoder`来验证用户请求登录时输入的密码,并且,此类型也会是认证成功后`Authentication`中的Principal。
使用Spring Security框架时涉及的文件
**pom.xml**
需要添加相关依赖,当需要使用Spring Security时,添加`spring-boot-starter-security`,当需要使用JWT时,添加`jjwt`(生成和解析JWT数据的工具包)和`fastjson`(实现对象与JSON字符串互相转换的工具包)。
**UserDetailsServiceImpl**
是`UserDetailsService`接口的实现类,需要重写其中的`UserDetails loadUserByUsername(String s)`方法,Spring Security在执行认证时会自动调用此方法,此方法的返回结果必须至少包括:密码、权限和其它必要的信息(根据API决定)。
关于返回的`UserDetails`,通常可能使用`User`类型,但是,此类型并不包含`id`等属性,所以,也可能自定义类实现`UserDetails`接口,或自定义类继承自`User`,然后作为返回的`UserDetails`对象。
**SecurityConfiguration**
是Spring Security框架的配置类,需要继承自`WebSecurityConfigurerAdapter`。此类可以添加`@EnableGlabalMethodSecurity(prePostEnabled = true)`注解,用于开启全局的方法上的授权检查(允许在处理请求的方法使用`@PreAuthorize`检查权限)。通常,在此类中会配置`PasswordEncoder`对应的`@Bean`方法(此方法也可以在其它配置类中),在执行认证时,Spring Security会自动使用此`PasswordEncoder`对象的`matches()`方法来验证密码。
- 如果密文是BCrypt算法生成的,则应该在`@Bean`方法中返回`BCryptPasswordEncoder`,如果没有密文(密码并未加密),则此方法中应该返回`NoOpPasswordEncoder`,以此类推
在此类中,还可能配置`AuthenticationManager`对应的`@Bean`,此方法一般是重写的方法,用于返回`AuthenticationManager`对象,用于在其它组件中执行认证,例如在Service中自动装配此类型的属性,并调用`authenticate()`方法来执行认证。
在此类中,比较重要的是重写`void configure(HttpSecurity http)`方法,在此方法内部对如何处理请求进行配置,通常,需要配置的有:
- `http.csrf().disable()`:禁用防止跨域伪造的攻击,是固定的配置
- `http.cors()`:在Spring Security的过滤器链中添加`CorsFilter`,以实现放行复杂的异步请求的预检。另外,还应该调用`http`参数对象及对应的链式方法进行一些配置:
- `authroizeRequests()`:对请求进行认证
- `antMatchers()`:匹配某些路径,此方法并不决定这些路径应该如何被处理
- `permitAll()`:允许此前的`antMatchers()`配置的路径的所有方法直接访问
- `anyRequest()`:匹配其它的任何请求(请求路径),即在此前调用的所有`antMatchers()`以外的请求,此方法也不决定这些请求应该如何被处理
- `authenticated()`:已经认证的
**AdminDetails**
是`UserDetails`接口的实现类,或`User`的子类,这个类的主要作用是对`User`类进行扩展,因为在开发实践中,需要的认证信息中通常还包括用户的id等信息,而Spring Security的`User`中并没有定义这些属性,所以,不满足开发需求,则其进行扩展。
当编写`UserDetailsServiceImpl`的`loadUserByUsername()`时,此方法应该返回`AdminDetails`类型的对象。
当调用`AuthenticationManager`的`authenticate()`方法时,返回结果中的Principal就是`AdminDetails`对象。
**JwtUtils**
主要定义生成JWT和解析JWT的方法,便于在其它组件中直接调用,而不必关心生成JWT和解析JWT的细节。
**JwtAuthorizationFilter**
这是处理JWT的过滤器,其主要作用是对客户端的请求头中的有效JWT进行解析,并将解析得到的结果封装到认证信息中,然后将认证信息到Spring Security的上下文中,以至于:
- Spring Security会自动从上下文中取出认证信息中的权限部分,用于自动判断权限,所以,在控制器中处理请求的方法上,只需要使用`@PreAuthroize`注解即可实现授权访问的检查
- 在控制器中处理请求的方法的参数列表中,可以添加`Authentication`参数,则在控制器中就可以获取认证信息,甚至,不使用`Authentication`参数,而是使用自定义的当事人类型,添加`@AuthenticationPrincipal`注解,就可以直接得到自定义的当事人信息
在此过滤器的实现过程中,需要注意:
- 对于明显无效的JWT(为`null`、是空字符串等)应该直接放行,因为有些请求本不应该携带JWT数据,例如登录、注册……
- 解析JWT是可能失败的,特别是JWT可能过期,则应该直接对相关的异常进行处理,当前组件是过滤器,是执行在所有其它组件之前的,所以,不能抛出异常使用Spring MVC统一处理异常的机制
- 从JWT中解析出相关数据后,应该封装到`UsernamePasswordAuthenticationToken`中,其中,权限信息应该封装到此类型的`authorities`属性中,用户的登录信息(当事人信息)应该封装到此类型的`principal`属性中,另外,如果某个其它的系统(其它项目)并没有权限相关的概念,此处的`authorities`也不能为空,否则,会被Spring Security视为“没有有效的认证信息”
- 一定要将认证信息存入到Spring Security的上下文中
- 为了避免后续使用中可能出现的某些问题(例如第1次访问携带JWT最终向上下文中存入信息,后续不再携带JWT也会视为已登录),应该在过滤器刚刚执行时,清除Spring Security的上下文中的信息
**LoginPrincipal**
主要用于封装当事人的多个属性,例如同时将id、 用户名存入到`UsernamePasswordAuthenticationToken`中去,后续,在控制器中处理请求的方法的参数列表中,就可以使用`@AuthenticationPrincipal LoginPrincipal loginPrincipal`参数来得到当前登录的当事人信息。
**其它相关类或实现:AdminMapper及相关**
必须实现“根据用户名查询管理员信息”的功能,且返回的结果中必须包含此管理员的权限列表。
**其它相关类或实现:AdminServiceImpl**
在处理登录的过程中,应该调用`AuthenticationManager`的`authenticate()`执行认证,并获取返回结果,然后,将返回结果中的必要数据用于生成JWT,作为业务方法的返回值。
**其它相关类或实现:AdminController**
在处理登录时,必须响应调用Service组件时返回的JWT数据。
在其它需要获取认证信息的方法中,在参数列表中添加`@AuthenticationPrincipal LoginPrincipal loginPrincipal`来获取当前登录的当事人信息。
当某个请求必须拥有某种权限才可以访问时,在方法上添加`@PreAuthorize`注解配置权限。