集成了SpringSecurity后,又花了一天时间集成了JWT,记录一下。 完整代码在我的Gitee仓库:FreeFancy
0、一些背景
HTTP是无状态的,我们不能确定两次请求是不是一个用户发出的,就必须要每次都进行认证。传统方法解决这个问题是采用session和cookie机制,虽然解决了问题,但有一些缺点:
- session通常是保存在内存中的,用户数量如果较多,服务器的压力大;
- 因为session是保存在最初认证的那台服务器上,换了一台服务器又需要认证,不能很好的适应分布式的环境;
- 有CSRF的风险。因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
后来人们提出了基于token的鉴权机制,它不需要服务器去记录用户的认证或会话信息,而是将token交给客户端保存,每次的请求的时候携带,服务器只要鉴定即可,这样做有一些优点:
- 服务器开销小;
- 不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利;
- 因为不依赖于session,就杜绝了CSRF的风险。
基于Token的鉴权机制应用最广的还是JWT(JSON WEB TOKEN) 。这里我们不多解释JWT,相关文档很多请自动搜索。
1、实战
前置工作
这里是在SpringBoot已经简单整合了SpringSecurity的情况下开始整合JWT,这里前置工作有: 因为是前后端分离的情况,我们统一返回JSON,所以实现了
- 登录成功的自定义处理;
- 登录失败的自定义处理;
- 未登录被拦截的自定义处理。
我们的用户信息是要在数据库中取的,所以我们要实现UserDetailsService 接口,返回一个保存了用户信息的UserDetails 对象,并在配置中注册。
部分配置文件
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(customizeAuthenticationEntryPoint);
http.formLogin()
.successHandler(customizeAuthenticationSuccessHandler)
.failureHandler(customizeAuthenticationFailureHandler);
http.logout()
.logoutSuccessHandler(customizeLogoutSuccessHandler);
http.authorizeRequests()
.antMatchers("/login", "/register").anonymous()
.antMatchers(SWAGGER_SOURCE_PERMIT_ALL.split(",")).permitAll()
.anyRequest().authenticated();
}
这里贴出其中一个自定义处理,其他都是类似的,返回相应结果的JSON信息。
@Component
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
ResponseJson responseJson = ResponseJson.failure(ResponseCode.USER_NOT_LOGGED_IN);
httpServletResponse.setContentType("text/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
}
}
导包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
思路
看了很多别人的博客,思路并不清晰,开始还以为这东西搞起来很复杂,最后弄下其实还是不难的,需要配置的东西并不多。
- 对于新登录的用户,我们只要在登录成功后将TOKEN返回!,原本的逻辑并不需要修改,只要在上面我们自定义登录成功的处理中返回生成好的TOKEN。
- 对于登录过的用户发出的携带TOKEN的请求,我们要给它放行。
- 对于没有携带TOKEN的用户请求(要拦截的路径),给它打回去。
- JWT TOKEN的生成和解析。
思路是很自然的,得益于SpringSecurity实现起来也不难。
1、登录成功后返回TOKEN
首先,/login 路径的请求是不需要携带TOKEN就可以访问的。
http.authorizeRequests()
.antMatchers("/login", "/register").anonymous()
如果是表单登录,我们可以直接使用SpringSecurity自带的登录处理,请求参数中携带username ,password ,请求发给/login 就可以了。 还记得我们的登录成功自定义处理嘛?我们在里面把token生成然后返回就可以了。
http.formLogin()
.successHandler(customizeAuthenticationSuccessHandler)
.failureHandler(customizeAuthenticationFailureHandler);
@Component
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
StringBuilder authoritiesStr = new StringBuilder();
for (GrantedAuthority authority : authorities) {
authoritiesStr.append(authority.toString());
authoritiesStr.append(JwtTokenUtils.SPLIT_CHAR);
}
String token = JwtTokenUtils.createToken(jwtUser.getId(), jwtUser.getUsername(), authoritiesStr.toString());
HashMap<String, String> hashMap = new HashMap<>(1);
hashMap.put("Authorization", token);
ResponseJson responseJson = ResponseJson.success(hashMap);
httpServletResponse.setContentType("text/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
}
}
这样我们就完成了第一步。
2、携带TOKEN的请求,进行放行
SpringSecurity本质是一个拦截器连(Filter Chain) ,我们需要自定义一个拦截器取处理请求中的TOKEN,并为请求放行。
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String headerToken = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
if(headerToken == null || !headerToken.startsWith(JwtTokenUtils.TOKEN_PREFIX)){
chain.doFilter(request, response);
return;
}
if(!JwtTokenUtils.isExpiration(headerToken.replace(JwtTokenUtils.TOKEN_PREFIX,""))){
CustomizeUsernamePasswordAuthenticationToken authentication = getAuthentication(headerToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
super.doFilterInternal(request, response, chain);
}
private CustomizeUsernamePasswordAuthenticationToken getAuthentication(String headerToken){
String token = headerToken.replace(JwtTokenUtils.TOKEN_PREFIX, "");
Long userId = JwtTokenUtils.getUserId(token);
String username = JwtTokenUtils.getUserName(token);
String authoritiesStr = JwtTokenUtils.getUserAuthority(token);
List<GrantedAuthority> authorities = new ArrayList<>();
for (String s : authoritiesStr.split(JwtTokenUtils.SPLIT_CHAR)) {
if (s != null && !"".equals(s)) {
authorities.add(new SimpleGrantedAuthority(s));
}
}
if(username != null){
return new CustomizeUsernamePasswordAuthenticationToken(userId, username, null, authorities);
}
return null;
}
}
我们要将这个Filter注册进SpringSecurity里,框架留有接口,很方便,但我们应该放在哪里呢?
http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter 是用来登录认证的,我们放在它前面,将TOKEN解析出的用户认证信息写进SecurityContext里,后续就可以放行了。 另外这里提一嘴,因为基于TOKEN的认证鉴权不依赖于session,所以我们可以把session禁用了,当然也可以大胆的把csrf防护关闭。
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.csrf().disable();
3、拦截对于未携带TOKEN的请求
不是登录请求,又未携带TOKEN信息,会被后续的拦截器拦截,肯定是进不到系统的,后续拦截器报错,我们只要处理异常即可。 这里甚至不用特意处理,因为这和之前未登录拦截处理是一样的。
http.exceptionHandling()
.authenticationEntryPoint(customizeAuthenticationEntryPoint);
@Component
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
ResponseJson responseJson = ResponseJson.failure(ResponseCode.USER_NOT_LOGGED_IN);
httpServletResponse.setContentType("text/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
}
}
4、生成和解析TOKEN
生成是在成功登录后返回,解析是在处理携带TOKEN的请求,解析出用户的认证信息。 那哪些信息要放在TOKEN里呢?username和authorities(权限信息)以及你想要的信息。 我们对UserDetailsService 的实现要求loadUserByUsername() 返回一个UserDetails 对象。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
自带的对象就可以,它是UserDetails 的实现类。
org.springframework.security.core.userdetails.User
但它不能保存用户的ID,我就想把ID保存进TOKEN里,下次携带的时候可以解析出来,那我就自己写一个实现类。
public class JwtUser implements UserDetails {
private final Long id;
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
public JwtUser(Long id, String username, Set<GrantedAuthority> authorities){
this.id = id;
this.username = username;
this.authorities = authorities;
}
public JwtUser(Long id, String username, String password, Set<GrantedAuthority> authorities){
this.id = id;
this.username = username;
this.password = password;
this.authorities = authorities;
}
public Long getId(){
return id;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities(){
return authorities;
}
@Override
public String getPassword(){
return password;
}
@Override
public String getUsername(){
return username;
}
@Override
public boolean isAccountNonExpired(){
return true;
}
@Override
public boolean isAccountNonLocked(){
return true;
}
@Override
public boolean isCredentialsNonExpired(){
return true;
}
@Override
public boolean isEnabled(){
return true;
}
}
这个对象里的信息,就是我们要保存在TOKEN里的信息。 然后我们需要生成和解析TOKEN的一个工具类,这里直接给出我的。
public class JwtTokenUtils {
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
public static final String SECRET = "idea@whut.edu.cn";
public static final String SPLIT_CHAR = ";";
private static final Long EXPIRATION = 60 * 60 * 3L;
private static final String AUTHORITY = "authority";
private static final String ID = "id";
public static String createToken(Long id, String username, String authority){
HashMap<String, Object> map = new HashMap<>(2);
map.put(ID, id);
map.put(AUTHORITY, authority);
return TOKEN_PREFIX + Jwts.builder()
.signWith(SignatureAlgorithm.HS512, SECRET)
.setClaims(map)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
.compact();
}
public static Long getUserId(String token){
Long id;
try {
id = (Long) getTokenBody(token).get(ID);
} catch (Exception e){
id = null;
}
return id;
}
public static String getUserName(String token){
String username;
try {
username = getTokenBody(token).getSubject();
} catch (Exception e){
username = null;
}
return username;
}
public static String getUserAuthority(String token){
return (String) getTokenBody(token).get(AUTHORITY);
}
private static Claims getTokenBody(String token){
return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}
public static boolean isExpiration(String token){
return getTokenBody(token).getExpiration().before(new Date());
}
}
生成TOKEN用在:
@Component
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
JwtUser jwtUser = (JwtUser) authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
StringBuilder authoritiesStr = new StringBuilder();
for (GrantedAuthority authority : authorities) {
authoritiesStr.append(authority.toString());
authoritiesStr.append(JwtTokenUtils.SPLIT_CHAR);
}
String token = JwtTokenUtils.createToken(jwtUser.getId(), jwtUser.getUsername(), authoritiesStr.toString());
HashMap<String, String> hashMap = new HashMap<>(1);
hashMap.put("Authorization", token);
ResponseJson responseJson = ResponseJson.success(hashMap);
httpServletResponse.setContentType("text/json;charset=utf-8");
httpServletResponse.getWriter().write(JSON.toJSONString(responseJson));
}
}
解析TOKEN用在:
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String headerToken = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
if(headerToken == null || !headerToken.startsWith(JwtTokenUtils.TOKEN_PREFIX)){
chain.doFilter(request, response);
return;
}
if(!JwtTokenUtils.isExpiration(headerToken.replace(JwtTokenUtils.TOKEN_PREFIX,""))){
CustomizeUsernamePasswordAuthenticationToken authentication = getAuthentication(headerToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
super.doFilterInternal(request, response, chain);
}
private CustomizeUsernamePasswordAuthenticationToken getAuthentication(String headerToken){
String token = headerToken.replace(JwtTokenUtils.TOKEN_PREFIX, "");
Long userId = JwtTokenUtils.getUserId(token);
String username = JwtTokenUtils.getUserName(token);
String authoritiesStr = JwtTokenUtils.getUserAuthority(token);
List<GrantedAuthority> authorities = new ArrayList<>();
for (String s : authoritiesStr.split(JwtTokenUtils.SPLIT_CHAR)) {
if (s != null && !"".equals(s)) {
authorities.add(new SimpleGrantedAuthority(s));
}
}
if(username != null){
return new CustomizeUsernamePasswordAuthenticationToken(userId, username, null, authorities);
}
return null;
}
}
因为SecurityContextHolder.getContext().setAuthentication(); 要求传入Authentication 的实现了,但框架自带的并不能携带我们要求携带的userId,所以自定义一个实现类。
public class CustomizeUsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private final Long userId;
private final Object principal;
private Object credentials;
public CustomizeUsernamePasswordAuthenticationToken(Long userId, Object principal, Object credentials) {
super(null);
this.userId = userId;
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public CustomizeUsernamePasswordAuthenticationToken(Long userId, Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.userId = userId;
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
public Long getUserId() {
return this.userId;
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated,
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
5、使用SecurityContext中的信息
每次请求,SecurityContext中保有我们放入的一小段信息,这份信息可以在该次请求的时候拿出来。
public class SecurityUtils {
public static Long getUserId(){
Object token = SecurityContextHolder.getContext().getAuthentication();
if (token instanceof CustomizeUsernamePasswordAuthenticationToken) {
return ((CustomizeUsernamePasswordAuthenticationToken) token).getUserId();
}
return null;
}
}
到这里就完成了集成,捋清了思路其实还是很简单的,可以根据业务调整的地方有很多,可以在理解原理后按需修改。 如有错误,恳请指出!
|