JSD-2204-Session-Token-JWT-Day12

1.开发自定义的登录流程

目前,在passport项目中,登录是由Security框架提供的页面的表单来输入用户名、密码,且由Security框架自动处理登录流程,不适合前后端分离的开发模式!所以,需要自行开发登录流程!

关于自定义的登录流程,主要需要:

  • 在业务逻辑实现类中,调用Security的验证机制来执行登录认证
  • 在控制器类中,自定义处理请求,用于接收登录请求及请求参数,并调用业务逻辑实现类来实现认证

1.1关于在Service中调用Security的认证机制:

当需要调用Security框架的认证机制时,需要使用AuthenticationManager对象,可以在Security配置类中重写authenticationManager()方法,在此方法上添加@Bean注解,由于当前类本身是配置类,所以Spring框架会自动调用此方法,并将返回的结果保存到Spring容器中:

@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}

IAdminService中添加处理登录的抽象方法:

void login(AdminLoginDTO adminLoginDTO);

AdminServiceImpl中,可以自动装配AuthenticationManager对象:

@Autowired
private AuthenticationManager authenticationManager;

并实现接口中的方法:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager执行认证
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    authenticationManager.authenticate(authentication);
    log.debug("认证通过!");
}

1.2在控制器中接收登录请求,并调用Service:

在根包下创建pojo.dto.AdminLoginDTO类:

@Data
public class AdminLoginDTO implements Serializable {
    private String username;
    private String password;
}

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

@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginDTO adminLoginDTO) {
    log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
    adminService.login(adminLoginDTO);
    return JsonResult.ok();
}

为了保证能对以上路径直接发起请求,需要将此路径(/admins/login)添加到Security配置类的“白名单”中。

完成后,启动项目,可以通过Knife4j的调试来测试登录,当登录成功时将响应正确,当用户名或密码错误时,将响应错误(需要统一处理异常)。

1.3注意:即使登录成功,也不可以实现其它请求的访问!

2.关于Session

HTTP协议本身是无状态协议,所以,无法识别用户的身份!

为了解决此问题,经编程时,引入了Session机制,用于保存用户的某些信息,可识别用户的身份!

Session的本身是在服务器端的内存中一个类似Map结构的数据,每个客户端在提交请求时,都会携带一个由服务器端首次响应时分配的Session ID,作为Map的Key,由于此Session ID具有极强的唯一性,所以,每个客户端的Session ID理论上都是不相同的,从而服务器可以识别客户端!

由于Session是保存在服务器端的内存中的,在一般使用时,并不适用于集群!

3.Token

Token:令牌,票据。

目前,推荐使用Token来保存用户的身份标识,使之可以用于集群!

相比Session ID是没有信息含义的,Token则是有信息含义的数据,当客户端向服务器端提交登录请求后,服务器商认证通过就会将此用户的信息保存在Token中,并将此Token响应到客户端,后续,客户端在每次请求时携带Token,服务器端即可识别用户的身份!

4.JWT

JWT = JSON Web Token

JWT是使用JSON格式表示一系列的数据的Token。

当需要使用JWT时,应该在项目中添加依赖:

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

然后,通过测试,实现生成JWT和解析JWT。

@Slf4j
public class JwtTests {

    // 密钥(盐)
    String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";

    // 测试生成JWT
    @Test
    public void testGenerateJwt() {
        // 准备Claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("name", "liulaoshi");

        // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
        String jwt = Jwts.builder()
                // Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "jwt")
                // Payload:用于添加自定义数据,并声明有效期
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + 3 * 60 * 1000))
                // Signature:用于指定算法与密钥(盐)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        log.debug("JWT = {}", jwt);
        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9
        // .
        // eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzMTUyMX0
        // .
        // TFyWBZ3l-y6rYbEYiVBbQjqnFNsFFR07K8lDES9TPs4

        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTM0N30.7rj8Lhus1EYXUxE4Zy1wx1WFpbvxIQEmya3-A9WZP20
        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M
    }

    @Test
    public void testParseJwt() {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object name = claims.get("name");
        log.debug("id={}", id);
        log.debug("name={}", name);
    }

}

如果JWT数据已经过期,将出现错误:

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-08-08T12:05:21Z. Current time: 2022-08-08T14:11:34Z, a difference of 7573854 milliseconds.  Allowed clock skew: 0 milliseconds.

如果JWT签名有误(JWT数据的最后一段出错,或生成与解析时使用的secretKey不同),将出现错误:

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|b:"HS256","typ":"jwt"}

4.1关于JWT在项目中的应用

4.1.1生成JWT

应该在用户登录时,视为”认证成功“后,生成JWT,并将此数据响应到客户端。

在业务层,调用AuthenticationManagerauthenticate()方法后,得到的返回结果例如:

UsernamePasswordAuthenticationToken [
	Principal=org.springframework.security.core.userdetails.User [
		Username=root, 
		Password=[PROTECTED], 
		Enabled=true, 
		AccountNonExpired=true, 
		credentialsNonExpired=true, 
		AccountNonLocked=true, 
		Granted Authorities=[权限列表]
	], 
	Credentials=[PROTECTED], 
	Authenticated=true, 
	Details=null, 
	Granted Authorities=[权限列表]
]

可以看到,认证返回的数据中将包含成功认证的用户信息,也是当初用于执行认证的信息(UserDetailsServiceImpl中返回的结果),可以从此认证结果中获取用户相关数据,并写入到JWT中,则需要:

  • 将业务接口中的登录方法返回值类型改为String,表示认证成功后返回的JWT
  • 将业务实现类中的登录方法返回值一并修改
  • 在业务实现类中,当认证成功后,获取需要写入到JWT中的数据(例如:用户名等),并生成JWT,返回JWT

关于业务实现类的登录方法:

@Override
public String login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager执行认证
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    Authentication authenticateResult = authenticationManager.authenticate(authentication);
    log.debug("认证通过,返回的结果:{}", authenticateResult);
    log.debug("认证结果中的Principal的类型:{}",
            authenticateResult.getPrincipal().getClass().getName());

    // 处理认证结果
    User loginUser = (User) authenticateResult.getPrincipal();
    log.debug("认证结果中的用户名:{}", loginUser.getUsername());

    // 生成JWT
    String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
    // 准备Claims
    Map<String, Object> claims = new HashMap<>();
    claims.put("username", loginUser.getUsername());
    // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
    String jwt = Jwts.builder()
            // Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "jwt")
            // Payload:用于添加自定义数据,并声明有效期
            .setClaims(claims)
            .setExpiration(new Date(System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000))
            // Signature:用于指定算法与密钥(盐)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成的JWT:{}", jwt);
    return jwt;
}

在控制器中,将处理登录请求的方法的返回值类型改为JsonResult<String>,并在调用业务方法时获取返回值,封装到返回的对象中:

@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
    log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
    String jwt = adminService.login(adminLoginDTO);
    return JsonResult.ok(jwt);
}

完成后,重启项目,在Knife4j的调试功能中,使用正常的用户名和密码发起登录请求,将响应JWT结果,例如:

{
  "state": 20000,
  "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJleHAiOjE2NjExNTIzOTUsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.rFACBsBY8w8oNpR80n2YiplsEUIqw5bnCIsC5UAqsww"
}

4.2在服务器端检查并解析JWT

经过以上登录认证并响应JWT后,客户端在后续发起请求时,应该自主携带JWT数据,而服务器端应该尝试检查并解析JWT。

由于客户端在发起多种不同请求时都应该携带JWT,且服务器端都应该检查并尝试解析,所以,服务器端检查并解析的过程,应该发生在比较”通用“的组件中,即无论客户端提交的是哪个路径的请求,这个组件都应该执行!通常,会使用”过滤器“组件进行处理。

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

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public JwtAuthorizationFilter() {
        log.debug("创建过滤器:JwtAuthorizationFilter");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("执行JwtAuthorizationFilter");

        // 过滤器链继续执行,相当于:放行
        filterChain.doFilter(request, response);
    }

}

关于客户端提交请求时携带JWT数据,业内通用的做法是在请求头中添加Authorization属性,其值就是JWT数据,所以,服务器端获取JWT的做法应该是:从请求头中的Authorization属性中获取JWT数据!

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.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;

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public JwtAuthorizationFilter() {
        log.debug("创建过滤器:JwtAuthorizationFilter");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        log.debug("执行JwtAuthorizationFilter");
        // 从请求头中获取JWT
        String jwt = request.getHeader("Authorization");
        log.debug("从请求头中获取JWT:{}", jwt);

        // 判断JWT数据是否不存在
        if (!StringUtils.hasText(jwt) || jwt.length() < 80) {
            log.debug("获取到的JWT是无效的,直接放行,交由后续的组件继续处理!");
            // 过滤器链继续执行,相当于:放行
            filterChain.doFilter(request, response);
            // 返回,终止当前方法本次执行
            return;
        }

        // 尝试解析JWT
        String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object username = claims.get("username");
        log.debug("从JWT中解析得到username:{}", username);

        // 准备Authentication对象,后续会将此对象封装到Security的上下文中
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("临时使用的权限"));
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                username, null, authorities);

        // 将用户信息封装到Security的上下文中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);
        log.debug("已经向Security的上下文中写入:{}", authentication);

        // 过滤器链继续执行,相当于:放行
        filterChain.doFilter(request, response);
    }

}

完成后,还需要将此过滤器添加在Security框架的UsernamePasswordAuthenticationFilter过滤器之前,需要在Security配置类中,先自动装配自定义的过滤器对象:

@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

然后,在configurer(HttpSecurity http)方法中添加:

// 将“JWT过滤器”添加在“认证过滤器”之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);

最后,在JWT过滤器执行之初,先清除Security上下文中的数据,以避免”一旦提交JWT将认证对象存入到Security上下文中,后续不携带JWT也能访问“的问题:

// 清除Security上下文中的数据
SecurityContextHolder.clearContext();

完成后,启动项目,在Knife4j的调试功能中,携带JWT可以发起任何需要登录才能访问的请求,反之,这些请求不携带JWT将不允许访问。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值