当前位置: 首页 > 工具软件 > l-passport > 使用案例 >

jsd2205-csmall-passport(Day13)

邰建业
2023-12-01

1. 解析JWT时可能出现的错误

如果使用过期的JWT,在解析时将出现错误:

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-09-06T17:33:03Z. Current time: 2022-09-08T09:04:26Z, a difference of 142283930 milliseconds.  Allowed clock skew: 0 milliseconds.

如果使用的JWT数据的签名有误,在解析时将出现错误:

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

如果使用的JWT数据格式有误,在解析时将出现错误:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg	!L��؈�������)]P�

后续,将需要对这3种异常进行捕获并处理!

2. 使用JWT实现认证

在使用Spring Security框架处理认证时,如果认证通过,必须把通过认证的用户的信息存入到SecurityContext(Spring Security框架的上下文)对象中,后续,Spring Security框架会自动的尝试从SecurityContext中获取认证信息,如果获取到有效的认证信息,则视为“已登录”,否则,将视为“未登录”!

使用JWT实现认证需要完成的开发任务:

  • 当认证通过时生成JWT,并将JWT响应到客户端
  • 当客户端后续提交请求时,应该自觉携带JWT,而服务器端将对JWT进行解析,如果解析成功,将得此客户端的用户信息,并将认证信息存入到SecurityContext

3. 当认证通过时生成JWT,并将JWT响应到客户端

首先,需要修改IAdminService中处理认证的方法(login()方法)的声明,将返回值类型修改为String

String login(AdminLoginInfoDTO adminLoginInfoDTO);

并且,AdminServiceImpl中方法的声明也同步修改,在实现过程中,当通过认证后,应该生成JWT并返回:

@Override
public String login(AdminLoginInfoDTO adminLoginInfoDTO) {
    log.debug("开始处理【登录认证】的业务,参数:{}", adminLoginInfoDTO);

    // 调用AuthenticationManager的authenticate()方法执行认证
    // 在authenticate()方法的执行过程中
    // Spring Security会自动调用UserDetailsService对象的loadUserByUsername()获取用户详情
    // 并根据loadUserByUsername()返回的用户详情自动验证是否启用、判断密码是否正确等
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginInfoDTO.getUsername(),
                    adminLoginInfoDTO.getPassword());
    Authentication authenticateResult
            = authenticationManager.authenticate(authentication);
    log.debug("Spring Security已经完成认证,且认证通过,返回的结果:{}", authenticateResult);
    log.debug("返回认证信息中的当事人(Principal)类型:{}", authenticateResult.getPrincipal().getClass().getName());
    log.debug("返回认证信息中的当事人(Principal)数据:{}", authenticateResult.getPrincipal());

    // 从认证返回结果中取出当事人信息
    User principal = (User) authenticateResult.getPrincipal();
    String username = principal.getUsername();
    log.debug("认证信息中的用户名:{}", username);

    // 生成JWT,并返回
    // 准备Claims值
    Map<String, Object> claims = new HashMap<>();
    claims.put("username", username);

    // JWT的过期时间
    Date expiration = new Date(System.currentTimeMillis() + 15 * 24 * 60 * 60 * 1000);
    log.debug("即将生成JWT数据,过期时间:{}", expiration);

    // JWT的组成:Header(头:算法和Token类型)、Payload(载荷)、Signature(签名)
    String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
    String jwt = Jwts.builder()
            // Header
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            // Payload
            .setClaims(claims)
            .setExpiration(expiration)
            // Signature
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("已经生成JWT数据:{}", jwt);
    return jwt;
}

提示:以上生成JWT的代码暂未封装!

最后,在AdminController中,将处理认证的方法(login()方法)的返回值类型由JsonResult<Void>修改为JsonResult<String>,并且,在方法体中,调用IAdminService的认证方法时,必须获取返回值,最终将此返回值封装到JsonResult对象中,响应到客户端:

// http://localhost:9081/admins/login
@ApiOperation("管理员管理")
@ApiOperationSupport(order = 88)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginInfoDTO adminLoginInfoDTO) {
    String jwt = adminService.login(adminLoginInfoDTO);
    return JsonResult.ok(jwt);
}

4. 解析JWT并处理SecurityContext

当客户端成功的通过认证后,将可以得到JWT,后续,客户端可以携带JWT提交请求,但是,作为服务器端,并不知道客户端将会向哪个URL提交请求,或者说,不管客户端向哪个URL提交请求,服务器端都应该尝试解析JWT,以识别客户端的身份,则解析JWT的代码可以使用“过滤器”组件来实现!

过滤器(Filter):是Java EE中的核心组件,此组件是最早接收到请求的组件!并且,此组件可作用于若干个请求的处理过程。

关于客户端携带JWT,业内通用的做法是:将JWT携带在请求头(Request Header)中名为Authorization的属性中!

所以,此过滤器将固定的通过请求头(Request Header)中的Authorization属性获取JWT数据,并尝试解析。

由于Spring Security框架判断是否登录的标准是:在SecurityContext中是否存在认证信息!所以,当成功解析JWT数据后,应该将认证信息保存到SecurityContext中。

另外,还有几个细节:

  • 一旦SecurityContext中存在认证信息,在后续的访问中,即使不携带JWT数据,只要在SecurityContext还存在此前存入的认证信息,就会被视为“已经通过认证”,所以,为了避免此问题,应该在接收到请求的那一刻就直接清除SecurityContext
  • 认证的过程应该是“先将认证信息存入到SecurityContext(由我们的过滤器执行),再判断是否是通过认证的状态(由Spring Security的过滤器等组件执行)”,所以,当前过滤器必须在Spring Security的相关过滤器之前执行。

所以,在根包下创建filter.JwtAuthorizationFilter类,以解析JWT、向SecurityContext中存入认证信息:

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.springframework.stereotype.Component;
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;

/**
 * 解析JWT的过滤器
 *
 * 1. 首先,清除SecurityContext中的认证信息
 * 2. 如果客户端没有携带JWT,则放行,由后续的组件进行处理
 * 3. 如果客户端携带了有效的JWT,则解析,并将解析结果用于创建认证对象,最终,将认证对象存入到SecurityContext
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        log.debug("处理JWT的过滤器开始执行……");

        // 清除SecurityContext中原有的认证信息
        // 避免曾经成功访问过,后续不携带JWT也能被视为“已认证”
        SecurityContextHolder.clearContext();

        // 尝试从请求头中获取JWT数据
        String jwt = request.getHeader("Authorization");
        log.debug("尝试从请求头中获取JWT数据:{}", jwt);

        // 判断客户端是否携带了有效的JWT数据,如果没有,直接放行
        if (!StringUtils.hasText(jwt) || jwt.length() < 113) {
            log.debug("获取到的JWT被视为【无效】,过滤器执行【放行】");
            filterChain.doFilter(request, response);
            return;
        }

        // 程序执行到此处,表示客户端携带了有效的JWT,则尝试解析
        log.debug("获取到的JWT被视为【有效】,则尝试解析……");
        String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
        String username = claims.get("username", String.class);
        log.debug("从JWT中解析得到【username】的值:{}", username);

        // 准备权限,将封装到认证信息中
        List<GrantedAuthority> authorityList = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限");
        authorityList.add(authority);

        // 准备存入到SecurityContext的认证信息
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(
                username, null, authorityList);

        // 将认证信息存入到SecurityContext中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

        log.debug("过滤器执行【放行】");
        filterChain.doFilter(request, response);
    }

}

然后,在SecurityConfiguration中自动装配此过滤器:

@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

并在configurer()方法中补充:

// 将JWT过滤器添加在Spring Security的UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthorizationFilter,
			UsernamePasswordAuthenticationFilter.class);

完成后,重启项目,在Knife4j的在线API文档中,先不携带JWT并使用正确的账号登录,然后,携带登录返回的JWT即可向那些不在白名单中的URL进行访问!

5. 关于账号的权限

当处理认证时,应该从数据库中查询出此用户的权限,并且,将权限封装到UserDetails对象中,当认证成功后,返回的认证对象中的当事人信息就会包含权限信息,接下来,可以将权限信息也写入到JWT中!

后续,在解析JWT时,也可以从中解析得到权限信息,并将权限信息存入到SecurityContext中,则后续Spring Security的相关组件可以实现对权限的验证!

6. 查询管理员的权限

在处理认证时,会调用AdminMapper接口中的AdminLoginInfoVO getLoginInfoByUsername(String username);方法,此方法的返回值应该包含管理员的权限。

则SQL语句大致是:

select
    ams_admin.id,
    ams_admin.username,
    ams_admin.password,
    ams_admin.enable,
    ams_permission.value
from ams_admin
left join ams_admin_role on ams_admin.id=ams_admin_role.admin_id
left join ams_role_permission on ams_admin_role.role_id=ams_role_permission.role_id
left join ams_permission on ams_role_permission.permission_id=ams_permission.id
where username='root';

为了保证查询结果可以封装权限信息,需要在返回值类型中添加属性:

@Data
public class AdminLoginInfoVO implements Serializable {

    private Long id;
    private String username;
    private String password;
    private Integer enable;
    private List<String> permissions; // 新增

}

然后,重新配置getLoginInfoByUsername()方法映射的SQL查询:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String usernanme); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
    SELECT
        <include refid="LoginQueryFields"/>
    FROM
        ams_admin
    LEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_id
    LEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_id
    LEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.id
    WHERE
        username=#{username}
</select>

<sql id="LoginQueryFields">
    <if test="true">
        ams_admin.id,
        ams_admin.username,
        ams_admin.password,
        ams_admin.enable,
        ams_permission.value
    </if>
</sql>

<!-- collection标签:用于配置返回结果类型中List类型的属性 -->
<!-- collection标签的ofType属性:List中的元素类型 -->
<!-- collection子级:需要配置如何创建出List中的每一个元素 -->
<resultMap id="LoginResultMap" 
           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>

完成后,应该及时测试!

 类似资料: