JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

前言

本章是基于上一章“JWT+SpringSecurity实现基于Token的单点登录(一):前期准备”的基础上进行开发的,如果前期准备还没有做好的,可点击链接至上一章。

代码地址:gitee​​​​​​​

一、JWT工具类

这里我们使用jjwt来构建我们的Token。首先导入jjwt的依赖包。

        <!--JWT-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

接着构建JwtTokenUtils工具类。

package com.shiep.jwtauth.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.HashMap;
import java.util.List;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 15:11
 * @description: JWT工具类
 * JWT是由三段组成的,分别是header(头)、payload(负载)和signature(签名)
 * 其中header中放{
 *   "alg": "HS512",
 *   "typ": "JWT"
 * } 表明使用的加密算法,和token的类型==>默认是JWT
 *
 */
public class JwtTokenUtils {

    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    //**,用于signature(签名)部分解密
    private static final String PRIMARY_KEY = "jwtsecretdemo";
    //签发者
    private static final String ISS = "Gent.Ni";
    // 添加角色的key
    private static final String ROLE_CLAIMS = "role";

    // 过期时间是3600秒,既是1个小时
    private static final long EXPIRATION = 3600L;

    // 选择了记住我之后的过期时间为7天
    private static final long EXPIRATION_REMEMBER = 604800L;

    /**
     * description: 创建Token
     *
     * @param username
     * @param isRememberMe
     * @return java.lang.String
     */
    public static String createToken(String username, List<String> roles, boolean isRememberMe) {
        long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION;
        HashMap<String, Object> map = new HashMap<>();
        map.put(ROLE_CLAIMS, roles);
        return Jwts.builder()
                //采用HS512算法对JWT进行的签名,PRIMARY_KEY是我们的**
                .signWith(SignatureAlgorithm.HS512, PRIMARY_KEY)
                //设置角色名
                .setClaims(map)
                //设置发证人
                .setIssuer(ISS)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
    }

    /**
     * description: 从token中获取用户名
     *
     * @param token
     * @return java.lang.String
     */
    public static String getUsername(String token){
        return getTokenBody(token).getSubject();
    }

    // 获取用户角色
    public static List<String> getUserRole(String token){
        return (List<String>) getTokenBody(token).get(ROLE_CLAIMS);
    }

    /**
     * description: 判断Token是否过期
     *
     * @param token
     * @return boolean
     */
    public static boolean isExpiration(String token){
        return getTokenBody(token).getExpiration().before(new Date());
    }

    /**
     * description: 获取
     *
     * @param token
     * @return io.jsonwebtoken.Claims
     */
    private static Claims getTokenBody(String token){
        return Jwts.parser()
                .setSigningKey(PRIMARY_KEY)
                .parseClaimsJws(token)
                .getBody();
    }
}
JwtTokenUtils类的createToken方法,传入参数UserName为用户名,roles是该用户的角色列表,isRememberMe代表是否记住我,从而选择Token的过期时间。getUsername和getUserRole方法分别用来读取Token中的用户名和该用户的角色列表。isExpiration方法用来判断该Token是否过期。

二、实现UserDetails,封装用户信息

package com.shiep.jwtauth.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.HashSet;
import java.util.List;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 15:27
 * @description: 实现UserDetails,封装用户信息,用于验证身份
 */
public class JwtAuthUser implements UserDetails {
    private Integer id;
    private String userName;
    private String password;
    private List<String> roles;
    private Collection<? extends GrantedAuthority> authorities;

    /**
     * description: 通过FXUser来创建JwtAuthUser
     *
     * @param user
     * @return
     */
    public JwtAuthUser(FXUser user){
        this.id=user.getId();
        this.userName=user.getName();
        this.password=user.getPassword();
        this.roles=user.getRoles();
    }

    /**
     * description: 鉴权最重要方法,通过此方法来返回用户权限
     * 
     * @param  
     * @return java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (roles!=null) {
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority(role));
            }
        }
        System.out.println("authorities:"+authorities);
        return authorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.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;
    }

    @Override
    public String toString() {
        return "JwtAuthUser{" +
                "id=" + id +
                ", username='" + userName + '\'' +
                ", password='" + password + '\'' +
                ", authorities=" + roles +
                '}';
    }
}
JwtAuthUser类实现了UserDetails,从而封装了用户信息,用于认证和鉴权。

三、实现UserDetailsService,从数据库加载用户信息(UserDetails)

package com.shiep.jwtauth.service.impl;

import com.shiep.jwtauth.entity.FXUser;
import com.shiep.jwtauth.entity.JwtAuthUser;
import com.shiep.jwtauth.repository.FXUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:26
 * @description: 实现UserDetailsService,从数据库中加载用户信息==》用户名、密码及角色名
 *
 *  Spring Security中进行身份验证的是AuthenticationManager接口,ProviderManager是它的一个默认实现,但它并不用来处理身份认证,
 *  而是委托给配置好的AuthenticationProvider,每个AuthenticationProvider会轮流检查身份认证。检查后或者返回Authentication对象或者抛出异常。
 *
 *  验证身份就是加载响应的UserDetails,看看是否和用户输入的账号、密码、权限等信息匹配。
 *  此步骤由实现AuthenticationProvider的DaoAuthenticationProvider(它利用UserDetailsService验证用户名、密码和授权)处理。
 *  包含 GrantedAuthority 的 UserDetails对象在构建 Authentication对象时填入数据。
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private FXUserRepository userRepository;

    /**
     * description: 通过用户名从数据库中读取该用户账户信息及权限信息
     *
     * @param userName 用户名
     * @return org.springframework.security.core.userdetails.UserDetails
     */
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        FXUser user = userRepository.findByName(userName);
        if(user==null){
            // 实际当用户不存在时,应该页面显示错误信息,并跳转到登录界面
            throw new UsernameNotFoundException("该用户不存在!");
        }
        user.setRoles(userRepository.getRolesByUserName(userName));
        System.out.println("UserDetailsServiceImpl==>loadUserByUsername:"+user.toString());
        return new JwtAuthUser(user);
    }
}
UserDetailsServiceImpl实现了UserDetailsService,只有一个方法loadUserByUsername,通过查询用户名从数据库中加载用户信息(UserDetails),这里是JwtAuthUser。

为了更清晰理解SpringSecurity的认证鉴权原理,下面讲解下SpringSecurity中一些核心类。

  • AuthenticationManager, 用户认证的管理类,所有的认证请求(比如login)都会通过提交一个token给AuthenticationManagerauthenticate()方法来实现。当然事情肯定不是它来做,具体校验动作会由AuthenticationManager将请求转发给具体的实现类来做。根据实现反馈的结果再调用具体的Handler来给用户以反馈。
  • AuthenticationProvider, 认证的具体实现类,一个provider是一种认证方式的实现,比如提交的用户名密码我是通过和DB中查出的user记录做比对实现的,那就有一个DaoProvider;如果我是通过CAS请求单点登录系统实现,那就有一个CASProvider
    前面讲了AuthenticationManager只是一个代理接口,真正的认证就是由AuthenticationProvider来做的。一个AuthenticationManager可以包含多个Provider,每个provider通过实现一个support方法来表示自己支持那种Token的认证。AuthenticationManager默认的实现类是ProviderManager
  • UserDetailService, 用户认证通过Provider来做,所以Provider需要拿到系统已经保存的认证信息,获取用户信息的接口spring-security抽象成UserDetailService
  • AuthenticationToken, 所有提交给AuthenticationManager的认证请求都会被封装成一个Token的实现,比如最容易理解的UsernamePasswordAuthenticationToken
  • SecurityContext,当用户通过认证之后,就会为这个用户生成一个唯一的SecurityContext,里面包含用户的认证信息Authentication。通过SecurityContext我们可以获取到用户的标识Principle和授权信息GrantedAuthrity。在系统的任何地方只要通过SecurityHolder.getSecruityContext()就可以获取到SecurityContext

                                              JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

这里先大概了解下,下面我们接着Coding。

四、配置登录校验拦截器

在配置登录校验拦截器前,我们一般先创建一个Model类,用来接收用户登录信息。

package com.shiep.jwtauth.model;

import lombok.Data;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:18
 * @description: 封装了用户登录时的信息
 */
@Data
public class LoginUser {
    private String username;
    private String password;
    private Boolean rememberMe;
}

 LoginUser类中有三个字段,用户名、密码、是否记住我。实际开发时还能加入验证码等。

配置好了LoginUser类后,我们接着来看看如何配置过滤器。

package com.shiep.jwtauth.filter;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import com.shiep.jwtauth.entity.JwtAuthUser;
import com.shiep.jwtauth.model.LoginUser;
import com.shiep.jwtauth.utils.JwtTokenUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

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.Collection;
import java.util.List;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:12
 * @description: 进行用户账号的验证==>认证功能
 *
 */
public class JwtLoginAuthFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;

    private ThreadLocal<Boolean> rememberMe = new ThreadLocal<>();

    public JwtLoginAuthFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 设置该过滤器地址
        super.setFilterProcessesUrl("/auth/login");
    }

    /**
     * description: 登录验证
     *
     * @param request
     * @param response
     * @return org.springframework.security.core.Authentication
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        LoginUser loginUser = new LoginUser();
        loginUser.setUsername(request.getParameter("username"));
        loginUser.setPassword(request.getParameter("password"));
        loginUser.setRememberMe(Boolean.parseBoolean(request.getParameter("rememberMe")));
        System.out.println(loginUser.toString());
        rememberMe.set(loginUser.getRememberMe());
        return authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword(), new ArrayList<>())
        );

    }

    /**
     * description: 登录验证成功后调用,验证成功后将生成Token,并重定向到用户主页home
     * 与AuthenticationSuccessHandler作用相同
     *
     * @param request
     * @param response
     * @param chain
     * @param authResult
     * @return void
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {

        // 查看源代码会发现调用getPrincipal()方法会返回一个实现了`UserDetails`接口的对象,这里是JwtAuthUser
        JwtAuthUser jwtUser = (JwtAuthUser) authResult.getPrincipal();
        System.out.println("JwtAuthUser:" + jwtUser.toString());
        boolean isRemember = rememberMe.get();
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
        for (GrantedAuthority authority : authorities){
            roles.add(authority.getAuthority());
        }
        System.out.println("roles:"+roles);
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), roles,isRemember);
        System.out.println("token:"+token);
        // 重定向无法设置header,这里设置header只能设置到/auth/login界面的header
        //response.setHeader("token", JwtTokenUtils.TOKEN_PREFIX + token);

        // 登录成功重定向到home界面
        // 这里先采用参数传递
        response.sendRedirect("/home?token="+token);
    }

    /**
     * description: 登录验证失败后调用,这里直接Json返回,实际上可以重定向到错误界面等
     * 与AuthenticationFailureHandler作用相同
     *
     * @param request
     * @param response
     * @param failed
     * @return void
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_LOGIN_FAILED,false)));
    }
}
JwtLoginAuthFilter实现了UsernamePasswordAuthenticationFilter,该拦截器的attemptAuthentication方法,通过request.getParameter方法来得到前端传来的登录参数,并构建LoginUser对象。PS:从代码看LoginUser是不是有点多余?我们是不是可以直接得到参数进行校验?嗯……是可以
的,但是原本正确的思路是通过从输入流中直接构建登录对象的,代码如下:
LoginUser loginUser = new ObjectMapper().readValue(request.getInputStream(), LoginUser.class);   
但是LoginUser的Boolean rememberMe字段,老是无法与前端数据进行匹对(将Boolean改成String也报错,错误信息如下),因此这里先采用这种方式读取数据吧。ps:有知道如何解决的小伙伴,麻烦告诉我下,感激不尽~
com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input

我们接着看attemptAuthentication方法,该方法从登录界面读取到数据后,通过authenticationManager.authenticate方法,让SpringSecurity去进行验证,不需要自己查数据库对用户名和密码进行配对。

attemptAuthentication方法进行校验后,有两种结果:成功或失败。当验证成功时将调用successfulAuthentication方法,失败调用unsuccessfulAuthentication方法。

successfulAuthentication方法中首先通过Authentication.getPrincipal()方法来得到当前用户的信息(UserDetails),接着通过用户名、角色列表和是否记住我来构建Token。原本应该Token将放在response的header中的,但是设置header只能设置在当前页面的response中?(有知道如何设置重定向后页面的header的小伙伴,麻烦告诉我下,感激不尽~(/ □ \))至于为什么要页面重定向先卖个关子,我们先往下看。

unsuccessfulAuthentication方法是用户认证失败后返回信息,这里用到了两个工具类,下面我们先来看看他们。

五、response状态码

package com.shiep.jwtauth.common;

import lombok.Getter;

/**
 * @author: 倪明辉
 * @date: 2019/3/7 17:13
 * @description: JWT认证==》认证结果的枚举类
 */
@Getter
public enum ResultEnum {

    /**
     * description: 认证结果状态码及信息
     *
     * @param null
     * @return
     */
    SUCCESS(101,"成功"),
    FAILURE(102,"失败"),
    USER_NEED_AUTHORITIES(201,"用户未登录"),
    USER_LOGIN_FAILED(202,"用户账号或密码错误"),
    USER_LOGIN_SUCCESS(203,"用户登录成功"),
    USER_NO_ACCESS(204,"用户无权访问"),
    USER_LOGOUT_SUCCESS(205,"用户登出成功"),
    TOKEN_IS_BLACKLIST(206,"此token为黑名单"),
    LOGIN_IS_OVERDUE(207,"登录已失效"),
    ;

    private Integer code;

    private String message;

    ResultEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    /**
     * description: 通过code返回message
     *
     * @param code
     * @return com.shiep.jwtauth.common.ResultEnum
     */
    public static ResultEnum parse(int code){
        ResultEnum[] values = values();
        for (ResultEnum value : values) {
            if(value.getCode() == code){
                return value;
            }
        }
        throw  new RuntimeException("Unknown code of ResultEnum");
    }
}
ResultEnum封装一些常用的状态码。
package com.shiep.jwtauth.common;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

/**
 * @author: 倪明辉
 * @date: 2019/3/7 17:23
 * @description: response返回结果集
 */
public class ResultVO implements Serializable {
    private static final long serialVersionUID = -5359028332240046810L;

    /**
     * description: 返回响应信息
     *
     * @param respCode
     * @param success
     * @return java.util.Map<java.lang.String,java.lang.Object>
     */
    public static Map<String, Object> result(ResultEnum respCode, Boolean success) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("code", respCode.getCode());
        map.put("message", respCode.getMessage());
        map.put("data", null);
        map.put("success",success);
        return map;
    }

    /**
     * description: 返回响应信息及Token
     *
     * @param respCode
     * @param jwtToken
     * @param success
     * @return java.util.Map<java.lang.String,java.lang.Object>
     */
    public final static Map<String, Object> result(ResultEnum respCode, String jwtToken, Boolean success) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("jwtToken",jwtToken);
        map.put("code", respCode.getCode());
        map.put("message", respCode.getMessage());
        map.put("data", null);
        map.put("success",success);
        return map;
    }
}
ResultVO用于返回结果集。

六、BasicAuthenticationFilter过滤器

package com.shiep.jwtauth.filter;

import com.shiep.jwtauth.utils.JwtTokenUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

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.Collection;
import java.util.HashSet;
import java.util.List;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:20
 * @description: 对所有请求进行过滤
 * BasicAuthenticationFilter继承于OncePerRequestFilter==》确保在一次请求只通过一次filter,而不需要重复执行。
 */
public class JwtPreAuthFilter extends BasicAuthenticationFilter {

    public JwtPreAuthFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    /**
     * description: 从request的header部分读取Token
     *
     * @param request
     * @param response
     * @param chain
     * @return void
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        System.out.println("BasicAuthenticationFilters");
        String tokenHeader = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
        System.out.println("tokenHeader:"+tokenHeader);
        // 如果请求头中没有Authorization信息则直接放行了
        if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtils.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果请求头中有token,则进行解析,并且设置认证信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
        super.doFilterInternal(request, response, chain);
    }

    /**
     * description: 读取Token信息,创建UsernamePasswordAuthenticationToken对象
     *
     * @param tokenHeader
     * @return org.springframework.security.authentication.UsernamePasswordAuthenticationToken
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
        //解析Token时将“Bearer ”前缀去掉
        String token = tokenHeader.replace(JwtTokenUtils.TOKEN_PREFIX, "");
        String username = JwtTokenUtils.getUsername(token);
        List<String> roles = JwtTokenUtils.getUserRole(token);
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (roles!=null) {
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority(role));
            }
        }
        if (username != null){
            return new UsernamePasswordAuthenticationToken(username, null, authorities);
        }
        return null;
    }
}

在JwtLoginAuthFilter中我们对登录的用户进行认证,认证成功时将生成Token给用户前端,之后前端保存该Token在Cookie或session中,当用户访问服务器时,只需携带该Token即可访问服务。而Token的认证鉴权工作就是由本例中的JwtPreAuthFilter来实现了。

JwtPreAuthFilter继承了BasicAuthenticationFilter,该过滤器是继承于OncePerRequestFilter,用于确保在一次请求只通过一次filter,而不需要重复执行。简单的说,就是用户的每次请求都将经过该过滤器。下面我们详细看看该过滤器中的代码。

doFilterInternal方法从request的header中查看是否带有Token,如果没有则放行,如果有则进行Token解析(调用getAuthentication方法),并设置认证信息。

 七、配置Handler

package com.shiep.jwtauth.handler;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: 倪明辉
 * @date: 2019/3/8 9:44
 * @description: 用户登出成功时返回给前端的数据
 */
public class FxLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        httpServletResponse.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_LOGOUT_SUCCESS,true)));
    }

}
package com.shiep.jwtauth.handler;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: 倪明辉
 * @date: 2019/3/7 15:18
 * @description: 用户未登录时返回给前端的数据
 */
public class UnAuthorizedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
//        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
//        String reason = "统一处理,原因:"+authException.getMessage();
//        response.getWriter().write(new ObjectMapper().writeValueAsString(reason));
        response.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_NEED_AUTHORITIES,false)));
    }
}
FxLogoutSuccessHandler是用户成功登出时调用的,而UnAuthorizedEntryPoint是用户未登录时调用的。Spring中handle还有许多,不一一列举了。

八、配置SpringSecurity

到这里基本操作都写好啦,现在就需要我们将这些辛苦写好的“组件”组合到一起发挥作用了,那就需要配置SpringSecurity了。

package com.shiep.jwtauth.config;

import com.shiep.jwtauth.filter.JwtLoginAuthFilter;
import com.shiep.jwtauth.filter.JwtPreAuthFilter;
import com.shiep.jwtauth.handler.FxLogoutSuccessHandler;
import com.shiep.jwtauth.handler.UnAuthorizedEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:24
 * @description:
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    // 因为UserDetailsService的实现类实在太多啦,这里设置一下我们要注入的实现类
    @Qualifier("userDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    // 加密器
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
     * description: 加载userDetailsService,用于从数据库中取用户信息
     * 
     * @param auth 
     * @return void
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    /**
     * description: http细节
     * 
     * @param http 
     * @return void
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 开启跨域资源共享
        http.cors()
                .and()
                // 关闭csrf
                .csrf().disable()
                // 关闭session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .httpBasic().authenticationEntryPoint(new UnAuthorizedEntryPoint())
                .and()
                .authorizeRequests()
                // 需要角色为ADMIN才能删除该资源
                .antMatchers(HttpMethod.DELETE,"/tasks/**").hasAnyRole("ADMIN")
                // 测试用资源,需要验证了的用户才能访问
                .antMatchers("/tasks/**").authenticated()
                // 其他都放行了
                .anyRequest().permitAll()
                .and()
                .formLogin()
                .loginPage("/login")
                .permitAll()
                //.successHandler(new Fx)
                .and()
                .logout()//默认注销行为为logout
                .logoutSuccessHandler(new FxLogoutSuccessHandler())
                .and()
                // 添加到过滤链中
                // 先是UsernamePasswordAuthenticationFilter用于login校验
                .addFilter(new JwtLoginAuthFilter(authenticationManager()))
                // 再通过OncePerRequestFilter,对其他请求过滤
                .addFilter(new JwtPreAuthFilter(authenticationManager()));
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }
}
configure(HttpSecurity http)方法是重点,具体在代码注释里面已经解释清楚了。下面我们来配置下Controller和视图。

注意:配置SpringSecurity,需要从细粒度到粗粒度。不然细粒度将不起作用。

九、thymeleaf视图

loginPage.html==》登录界面

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Spring Security Example </title>
    <script>
        function changeValue(){
            var check = document.getElementById("rememberMe");
            if(check.checked == true){
                document.getElementById("rememberMe").value = true;
            }else{
                document.getElementById("rememberMe").value = false;
            }
        }
    </script>
</head>
<body>
<div th:if="${param.error}">
    Invalid username and password.
</div>
<div th:if="${param.logout}">
    You have been logged out.
</div>
<form th:action="@{/auth/login}" method="post">
    <div><label> User Name : <input type="text" name="username"/> </label></div>
    <div><label> Password: <input type="password" name="password"/> </label></div>
    <div><label> RememberMe: <input type="checkbox" name="rememberMe" id="rememberMe" onclick="changeValue()"/></label></div>
    <div><input type="submit" value="Sign In"/></div>
</form>
</body>
</html>

 loginPage页面,定义了username、password和rememberMe来得到用户登录信息(看前面的登录认证过滤器),接着提交到“/auth/login”路径,该路径就是前面JwtLoginAuthFilter中配置的路径,提交到该过滤器中进行登录验证。

homePage.html==》用户登录验证成功后进入的用户主页

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Spring Security Example </title>
    <script>
        function changeValue(){
            var check = document.getElementById("rememberMe");
            if(check.checked == true){
                document.getElementById("rememberMe").value = true;
            }else{
                document.getElementById("rememberMe").value = false;
            }
        }
    </script>
</head>
<body>
<div th:if="${param.error}">
    Invalid username and password.
</div>
<div th:if="${param.logout}">
    You have been logged out.
</div>
<form th:action="@{/auth/login}" method="post">
    <div><label> User Name : <input type="text" name="username"/> </label></div>
    <div><label> Password: <input type="password" name="password"/> </label></div>
    <div><label> RememberMe: <input type="checkbox" name="rememberMe" id="rememberMe" onclick="changeValue()"/></label></div>
    <div><input type="submit" value="Sign In"/></div>
</form>
</body>
</html>

homePage.html中导入了jquery和自己写的一个getToken.js,用于从URL中得到参数。而按钮的点击事件调用deleteTask()方法,用于发送delete请求到“/tasks”,该路径需要“ROLE_ADMIN”权限。

/*获取到Url里面的参数*/
(function ($) {
    $.getUrlParam = function (name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = window.location.search.substr(1).match(reg);
        if (r != null) return unescape(r[2]); return null;
    }
})(jQuery);

十、Controller控制层

package com.shiep.jwtauth.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * @author: 倪明辉
 * @date: 2019/3/8 10:31
 * @description:
 */
@Controller
public class LoginController {
    @GetMapping("/login")
    public String toLoginPage(){
        return "loginPage";
    }

    @GetMapping("/home")
    public String toHomePage(){
        return "homePage";
    }
}
LoginController中定义了“/login”路径指向loginPage.html页面,“/home”路径指向homePage.html页面。
package com.shiep.jwtauth.controller;

import org.springframework.web.bind.annotation.*;

/**
 * @author: 倪明辉
 * @date: 2019/3/6 16:35
 * @description:
 */
@RestController
@RequestMapping(path = "/tasks",produces = "application/json;charset=gbk")
public class TaskController {

    @GetMapping
    public String listTasks(){
        return "任务列表";
    }

    @PostMapping
    public String newTasks(){
        return "创建了一个新的任务";
    }

    @PutMapping("/{taskId}")
    public String updateTasks(@PathVariable("taskId")Integer id){
        return "更新了一下id为:"+id+"的任务";
    }

    @DeleteMapping("/{taskId}")
    public String deleteTasks(@PathVariable("taskId")Integer id){
        return "删除了id为:"+id+"的任务";
    }
}
TaskController就是对应SpringSecurity配置类中的需要权限才能访问的路径。

好了,终于开发完成了,接下来我们运行项目来测试下吧。

十一、运行结果测试

首先,我们在数据库中插入两个用户,通过上一章的方法进行插入用户。

用户名:wang,密码:123(数据库中的密码是经过加密后的),该用户具有的权限(ROLE_USER、ROLE_ADMIN)

用户名:li,密码:123(数据库中的密码是经过加密后的),该用户具有的权限(ROLE_USER)

接着使用浏览器访问http://localhost:8080/tasks,即get方式。因为“/tasks/**”路径是需要权限认证的,但是我们此时未验证(在header中设置Token),因此会跳转到http://localhost:8080/login界面。

JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

我们在这个界面使用wang用户登录。页面从“/login”跳转到“/auth/login”路径进行认证,认证成功重定向到“/home”页面,并通过参数形式携带Token到前台。现在我们看看程序的控制台输出:

JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

可以看到认证成功了,Token已经生成。下面是home界面。

JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

当我们点击按钮时:

JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

可以看到权限认证成功,已经执行方法。

接着,我们重新以li用户登录试试,步骤同上。

JWT+SpringSecurity实现基于Token的单点登录(二):认证和授权

发现发生403错误,这是用户无权限。

十二、后记

以上关于使用JWT+SpringSecurity实现基于Token的单点登录之认证和授权部分已经完成。但是除了上面提到的,还有一些问题:因为JWT的最大缺点是服务器不保存会话状态,所以在使用期间不可能取消令牌或更改令牌的权限。也就是说,一旦JWT签发,在有效期内将会一直有效。在实际情况下,用户登出时,应该使本次的Token失效。这是其中一个问题。另外关于JWT,我们还能继续跟Redis进行集成,将其缓存到Redis中。最后,跟SpringCloud进行继承,作为Zuul网关的认证入口。这些我们将在下一章继续开发。