关于Token与JWT_token 与 jwt-程序员宅基地

技术标签: java  开发语言  

关于Token与JWT

Token:票据,令牌。

当用户尝试登录,将请求提交到服务器端,如果服务器端认证通过,会生成一个Token数据并响应到客户端,此Token是有意义的数据,此客户端在后续的每一次请求中,都应该携带此Token数据,服务器端通过解析此Token来识别用户身份!

关于Session与Token:Session默认是保存在服务器的内存中的数据,会占用一定的服务器内存资源,并且,不适合集群或分布式系统(虽然可以通过共享Session来解决),客户携带的Session ID只具有唯一性的特点(理论上),不具备数据含义……而Token的本质是将有意义的数据进行加密处理后的结果,各服务器都只需要具有解析这个加密数据的功能即可获取到其中的信息含义,理论上不占用内存资源,更适用于集群和分布式系统,但是,存在一定的被解密的风险(概率极低)。

JWT = JSON Web Token,是使用JSON格式表示多项数据的Token。

在使用JWT之前,需要在项目中添加相关的依赖,用于生成JWT和解析JWT,例如添加:

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

提示:更多依赖项可参考 https://jwt.io/libraries?language=Java

一个原始的JWT数据应该包含3个部分:

HEADER:ALGORITHM & TOKEN TYPE(算法与Token类型)

{
    
  "alg": "HS256",
  "typ": "JWT"
}

PAYLOAD(载荷):DATA

此部分的数据是自定义的,可按需存入任何所需的数据。

{
    
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

VERIFY SIGNATURE(验证签名)

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

使用jjwt生成和解析JWT数据的示例:

package cn.tedu.csmall.passport;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JwtTests {
    

    // Secret Key
    String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";

    @Test
    public void testGenerate() {
    
        // 准备Claims值
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("name", "LiuLaoShi");
        claims.put("nickname", "JavaCangLaoShi");

        // JWT的过期时间
        Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000);
        System.out.println("过期时间:" + expiration);

        // JWT的组成:Header(头:算法和Token类型)、Payload(载荷)、Signature(签名)
        String jwt = Jwts.builder()
                // Header
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload
                .setClaims(claims)
                .setExpiration(expiration)
                // Signature
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        System.out.println("JWT=" + jwt);

        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NDg5NH0.mHYjK70qenmqmQ5_NrjZsh2P0t-QPKvBedVDRqH2ed8
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NTA0NH0._7o_k9s3we-Ti-9rO4FpYzWxPxNDTFaLbAjZz-bOa8M

        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
        // .
        // eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTUwOTV9
        // .
        // KaiBd1LskHVPZzwfDdeoZOCHQ4FB-P_69at0g-1jyqs
    }

    @Test
    public void testParse() {
    
        // 注意:必须使用相同secretKey生成的JWT,否则会解析失败
        // 注意:不可以使用过期的JWT,否则会解析失败
        // 注意:复制粘贴此JWT时,不要带“尾巴”,否则会解析失败
        // 注意:不可以恶意修改JWT中的任何字符,否则会解析失败
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTY3ODN9.32MwkSbDz1ce4EvEKHFMCIjcQFUDZz6hn5MtAYr0njQ";

        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
        Integer id = claims.get("id", Integer.class);
        String name = claims.get("name", String.class);
        String nickname = claims.get("nickname", String.class);
        System.out.println("id = " + id);
        System.out.println("name = " + name);
        System.out.println("nickname = " + nickname);
    }

}

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 [email protected]
 * @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>

完成后,应该及时测试!

1. 使用JWT保存权限

UserDetailsServiceImpl中,调用的adminMapper.getLoginInfoByUsername()中已经包含用户的权限,则,在返回的UserDetails对象中封装权限信息:

UserDetails userDetails = User.builder()
                .username(loginAdmin.getUsername())
                .password(loginAdmin.getPassword())
                .accountExpired(false)
                .accountLocked(false)
                .credentialsExpired(false)
                .disabled(loginAdmin.getEnable() == 0)
                .authorities(loginAdmin.getPermissions().toArray(new String[] {
    })) // 调整
                .build();

AdminServiceImpl中,执行认证且成功后,返回的Authentication对象中的“当事人”就是以上返回的UserDetails对象,所以,此对象中是包含了以上封装的权限信息的,则可以将权限信息取出并封装到JWT中。

需要注意:如果直接将权限(Collection<? extends GrantedAuthority>)存入到JWT数据中,相当于把Collection<? extends GrantedAuthority>转换成String,此过程会丢失数据的原始类型,且不符合自动反序列化格式,后续解析时,无法直接还原成Collection<? extends GrantedAuthority>类型!为解决此问题,可以先将Collection<? extends GrantedAuthority>转换成JSON格式的字符串再存入到JWT中,后续,解析JWT时得到的也会是JSON格式的字符串,可以反序列化为Collection<? extends GrantedAuthority>格式!

则先添加JSON工具类的依赖项:

<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>

然后,在AdminServiceImpl中,先从认证成功的返回结果中取出权限,然后存入到JWT中:

// 从认证返回结果中取出当事人信息
User principal = (User) authenticateResult.getPrincipal();
String username = principal.getUsername();
log.debug("认证信息中的用户名:{}", username);
// ===== 以下是新增 ======
Collection<GrantedAuthority> authorities = principal.getAuthorities();
log.debug("认证信息中的权限:{}", authorities);
String authorityListString = JSON.toJSONString(authorities);
log.debug("认证信息中的权限转换为JSON字符串:{}", authorityListString);

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

最后,在JwtAuthorizationFilter中,解析JWT时,取出权限的JSON字符串,将其反序列化为符合Collection<? extends GrantedAuthority>的格式:List<SimpleGrantedAuthority>,并用于存入到认证信息中:

String username = claims.get("username", String.class);
log.debug("从JWT中解析得到【username】的值:{}", username);
String authorityListString = claims.get("authorities", String.class); // 新增
log.debug("从JWT中解析得到【authorities】的值:{}", authorityListString); // 新增

// 准备权限,将封装到认证信息中
List<SimpleGrantedAuthority> authorityList
        = JSON.parseArray(authorityListString, SimpleGrantedAuthority.class);

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

2. 使用Spring Security控制访问权限

首先,需要在Spring Security的配置类上开启方法前的权限检查:

@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
    // 省略配置类中原有代码
}

然后,在需要对权限进行检查(控制)的控制器类的方法上,使用注解来配置权限,例如:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 400)
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list() {
    
    log.debug("开始处理【查询管理员列表】的请求……");
    List<AdminListItemVO> list = adminService.list();
    return JsonResult.ok(list);
}

以上新增的@PreAuthorize("hasAuthority('/ams/admin/read')")就表示已经通过认证的用户必须具有 '/ams/admin/read' 权限才可以访问此请求路径(http://localhost:9081/admins),如果没有权限,将抛出org.springframework.security.access.AccessDeniedException: 不允许访问

由于无操作权限时会出现新的异常,则在GlobalExceptionHandler中补充对此类异常的处理:

@ExceptionHandler
public JsonResult<Void> handleAccessDeniedException(AccessDeniedException e) {
    
    log.debug("处理AccessDeniedException");
    Integer serviceCode = ServiceCode.ERR_FORBIDDEN.getValue();
    String message = "请求失败,当前账号无此操作权限!";
    return JsonResult.fail(serviceCode, message);
}

3. 在控制器中识别当前登录的用户

当已经通过认证的用户访问服务器时,将携带JWT数据,而JWT数据在过滤器(JwtAuthorizationFilter)就已经解析完成,如果在控制器中需要识别用户的身份,只在在过滤器将用户信息存储到认证信息(Authentication)中,并且,在控制器中获取相关数据!

通常,识别用户的身份时,需要获取当前登录的用户的id,Spring Security处理认证时,需要的用户信息的数据类型是UserDetails接口类型的,并且,Spring Security提供了User作为此接口类型的实现,但是,User类型中并没有id、头像、昵称等各软件设计时的个性化数据属性,在开发实践时,为了保证能够得到这些个性化数据,应该使用自定义类型实现UserDetails接口,或者,自定义类型继承自User类,并在UserDetailsServiceImpl中返回此类对象!

则在根包下创建security.AdminDetails类:

package cn.tedu.csmall.passport.security;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public class AdminDetails extends User {
    

    private Long id;

    public AdminDetails(String username, String password, boolean enabled,
                        Collection<? extends GrantedAuthority> authorities) {
    
        super(username, password, enabled, true, true, true, authorities);
    }

    public Long getId() {
    
        return id;
    }

    public void setId(Long id) {
    
        this.id = id;
    }
    
}

在实现UserDetailsService接口时,此前返回的对象都是User对象,现在就可以返回自定义的AdminDetails对象了:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    
    log.debug("Spring Security自动调用loadUserByUsername()方法获取用户名为【{}】的用户详情……", s);

    AdminLoginInfoVO loginAdmin = adminMapper.getLoginInfoByUsername(s);
    log.debug("从数据库中查询到的用户信息:{}", loginAdmin);
    if (loginAdmin == null) {
    
        String message = "登录失败,用户名不存在!";
        log.warn(message);
        throw new BadCredentialsException(message);
    }

    List<String> permissions = loginAdmin.getPermissions();
    List<GrantedAuthority> authorities = new ArrayList<>();
    for (String permission : permissions) {
    
        authorities.add(new SimpleGrantedAuthority(permission));
    }

    AdminDetails adminDetails = new AdminDetails(
                loginAdmin.getUsername(), loginAdmin.getPassword(),
                loginAdmin.getEnable() == 1, authorities);
    adminDetails.setId(loginAdmin.getId());

//        UserDetails userDetails = User.builder()
//                .username(loginAdmin.getUsername())
//                .password(loginAdmin.getPassword())
//                .accountExpired(false) // 账号是否已过期
//                .accountLocked(false) // 账号是否已锁定
//                .credentialsExpired(false) // 凭证是否已过期
//                .disabled(loginAdmin.getEnable() == 0) // 账号是否已禁用
//                .authorities(loginAdmin.getPermissions().toArray(new String[] {})) // 权限,【注意】必须调用此方法表示此用户具有哪些权限
//                .build();
    log.debug("即将向Spring Security框架返回UserDetails对象:{}", adminDetails);
    return adminDetails;
}

以上方法返回的对象仍是Spring Security处理认证时判断是否允许登录的对象,也是认证成功后返回的认证信息中的当事人,所以,在AdminServiceImpllogin()方法中,当认证成功后,可以获取认证信息中的当事人,并从中获取到id等信息,用于保存到JWT数据:

// 从认证返回结果中取出当事人信息
AdminDetails principal = (AdminDetails) authenticateResult.getPrincipal(); // 修改
Long id = principal.getId(); // 新增
log.debug("认证信息中的用户id:{}", id); // 新增
String username = principal.getUsername();
log.debug("认证信息中的用户名:{}", username);
Collection<GrantedAuthority> authorities = principal.getAuthorities();
log.debug("认证信息中的权限:{}", authorities);
String authorityListString = JSON.toJSONString(authorities);
log.debug("认证信息中的权限转换为JSON字符串:{}", authorityListString);

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

至此,当用户通过认证时,得到的JWT数据中将包含此用户的id。

通常,在控制器中需要识别用户的身份时,需要的信息可能有多个,例如用户的id、用户名等,可以将这些信息封装到自定义对象中,例如,在根包下创建security.LoginPrincipal类:

package cn.tedu.csmall.passport.security;

import lombok.Data;

import java.io.Serializable;

@Data
public class LoginPrincipal implements Serializable {
    

    private Long id;
    private String username;

}

然后,在过滤器(JwtAuthorizationFilter)中,当解析JWT时,就可以从中获取id与用户名,并使用这2个值来创建LoginPrincipal对象,最后,将LoginPrincipal对象封装到认证信息的当事人中:

Long id = claims.get("id", Long.class); // 新增
log.debug("从JWT中解析得到【id】的值:{}", id); // 新增
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到【username】的值:{}", username);
String authorityListString = claims.get("authorities", String.class);
log.debug("从JWT中解析得到【authorities】的值:{}", authorityListString);

// 准备权限,将封装到认证信息中
List<SimpleGrantedAuthority> authorityList
        = JSON.parseArray(authorityListString, SimpleGrantedAuthority.class);

// 创建自定义的当事人类型的对象
LoginPrincipal loginPrincipal = new LoginPrincipal(); // 新增
loginPrincipal.setId(id); // 新增
loginPrincipal.setUsername(username); // 新增

// 准备存入到SecurityContext的认证信息
Authentication authentication
        = new UsernamePasswordAuthenticationToken(
            loginPrincipal, null, authorityList);  // 修改了第1个参数值,改为loginPrincipal

至此,当客户端携带(最新的)JWT到服务器端时,过滤器可以解析得到idusername,并且,这些属性最终将保存到SecurityContext的认证信息中,则后续控制器可以随时获取这些信息,例如:

@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 400)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
    	// 下一行的参数声明是新增的
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    
    log.debug("开始处理【查询管理员列表】的请求……");
    log.debug("从SecurityContext中获取到的信息:"); // 新增
    log.debug("当事人id = {}", loginPrincipal.getId()); // 新增
    log.debug("当事人用户名 = {}", loginPrincipal.getUsername()); // 新增
    List<AdminListItemVO> list = adminService.list();
    return JsonResult.ok(list);
}

4. 关于secretKey值

AdminServiceImpl中生成JWT、在JwtAuthorizationFilter中解析JWT,都需要使用到相同的secretKey值,目前,在这2个代码片段中各自使用局部变量声明了此变量,并且,2个文件中的这2个变量的值是相同的,但是,各声明一个局部变量是不合理的!

可以在application.properties中添加自定义配置:

# 当前项目的自定义配置:JWT使用的secretKey
csmall.jwt.secret-key=97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds

然后,在这2个类中都添加:

@Value("${csmall.jwt.secret-key}")
private String secretKey;

各这2个类都可以读取到application.properties中的配置值,不必再各自声明secretKey局部变量了!

另外,建议将“JWT的有效时长”也进行类似的处理,例如:

# 当前项目的自定义配置:JWT的有效时长,以分钟为单位
csmall.jwt.duration-in-minute=10000

5. 处理解析JWT时可能出现的异常

由于解析JWT是在过滤器(JwtAuthorizationFilter)中执行的,而过滤器是Java EE中最早接收到请求的组件,如果此时出现异常,Spring MVC框架的相关组件还没有开始执行,即“全局异常处理器”是不会发挥作用的!

对于解析JWT可能出现的异常,应该由过滤器组件直接进行处理!

首先,在ServiceCode中补充新的业务状态码:

public enum ServiceCode {
    

    OK(20000),
    ERR_BAD_REQUEST(40000),
    ERR_UNAUTHORIZED(40100),
    ERR_UNAUTHORIZED_DISABLED(40110),
    ERR_FORBIDDEN(40300),
    ERR_NOT_FOUND(40400),
    ERR_CONFLICT(40900),
    ERR_INSERT(50000),
    ERR_DELETE(50100),
    ERR_UPDATE(50200),
    ERR_JWT_EXPIRED(60000), // 新增
    ERR_JWT_PARSE(60100); // 新增
    
    // 省略其它原有代码

然后,在过滤器中,解析JWT时,使用try...catch语法捕获并处理异常:

// 程序执行到此处,表示客户端携带了有效的JWT,则尝试解析
log.debug("获取到的JWT被视为【有效】,则尝试解析……");
Claims claims = null;

response.setContentType("application/json; charset=utf-8");

try {
    
    claims = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(jwt)
            .getBody();
} catch (ExpiredJwtException e) {
    
    log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
    Integer serviceCode = ServiceCode.ERR_JWT_EXPIRED.getValue();
    String message = "登录信息已过期,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
    String jsonString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonString);
    writer.close();
    return;
} catch (SignatureException e) {
    
    log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
    Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
    String message = "无法获取到有效的登录信息,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
    String jsonString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonString);
    writer.close();
    return;
} catch (MalformedJwtException e) {
    
    log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
    Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
    String message = "无法获取到有效的登录信息,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
    String jsonString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonString);
    writer.close();
    return;
} catch (Throwable e) {
    
    log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
    Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
    String message = "无法获取到有效的登录信息,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
    String jsonString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonString);
    writer.close();
    e.printStackTrace();
    return;
}

6. 结合前端的登录页面

目前,后端的登录功能,如果成功登录,响应:

{
    
  "state": 20000,
  "message": null,
  "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZXhwIjoxNjYyNzEyNDMxLCJhdXRob3JpdGllcyI6Ilt7XCJhdXRob3JpdHlcIjpcIi9hbXMvYWRtaW4vZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvdXBkYXRlXCJ9XSIsInVzZXJuYW1lIjoicm9vdCJ9.VRK8btzrHmwU7gQ7Hu0-6nLYPYvh6-KlXSBTVH2NjAE"
}

如果用户名错误,响应:

{
    
  "state": 40100,
  "message": "登录失败,用户名或密码错误!",
  "data": null
}

如果密码错误,响应:

{
    
  "state": 40100,
  "message": "登录失败,用户名或密码错误!",
  "data": null
}

如果账号被禁用,响应:

{
    
  "state": 40110,
  "message": "登录失败,此账号已经禁用!",
  "data": null
}

所以,在前端的登录页面提交请求:

onSubmit(formName) {
    
  this.$refs[formName].validate((valid) => {
    
    if (valid) {
    
      let url = 'http://localhost:9081/admins/login';
      console.log('请求路径:' + url);
      console.log('请求参数:');
      console.log(this.form);
      let formData = this.qs.stringify(this.form);
      this.axios.post(url, formData).then((response) => {
    
        console.log('服务器端的响应:');
        console.log(response);
        let responseBody = response.data;
        if (responseBody.state == 20000) {
    
          // 登录成功,服务器端将响应JWT
          let jwt = responseBody.data;
          console.log('登录成功,服务器端响应的JWT:' + jwt);
          // 使用LocalStorage存储JWT
          localStorage.setItem('jwt', jwt);
          console.log('将JWT数据保存到LocalStorage!');
          // 测试:从LocalStorage中取出JWT
          let localJwt = localStorage.getItem('jwt');
          console.log('测试:从LocalStorage中取出JWT:' + localJwt);
          // 提示
          this.$message({
    
            message: '登录成功!(暂不跳转)',
            type: 'success'
          });
        } else {
    
          // 用户名或密码错误、账号被禁用
          this.$message.error(responseBody.message);
        }
      });
    } else {
    
      alert('参数格式有误,不允许提交!');
      return false;
    }
  });
}

在后续的访问中,应该携带JWT再提交请求,例如:

loadAdminList() {
    
  console.log('准备从服务器获取管理员列表……');
  let jwt = localStorage.getItem('jwt'); // 新增
  console.log('从LocalStorage中取出JWT:' + jwt); // 新增
  let url = 'http://localhost:9081/admins';
  this.axios
      .create({
    'headers': {
    'Authorization': jwt}}) // 新增
      .get(url).then((response) => {
    
    console.log('服务器端响应的结果:')
    console.log(response);
    let responseBody = response.data;
    if (responseBody.state == 20000) {
    
      this.tableData = responseBody.data;
    } else {
    
      this.$message.error(responseBody.message);
    }
  });
}

7. 关于Spring Security的跨域与PreFlight

PreFlight:预检

当客户端提交异步请求时,如果自定义了非常规的请求头,则此请求会被视为“复杂请求”,会触发PreFlight(预检)机制!

当触发PreFlight时,客户端会自动提交一个OPTIONS类型的请求到服务器端,如果服务器端没有对此请求放行,则会出现403错误!

所以,可以选择:

解决方案1:使得Spring Security对所有OPTIONS请求放行:

http.authorizeRequests()
                .antMatchers(urls)
                .permitAll()

                .antMatchers(HttpMethod.OPTIONS,"/**") // 新增
                .permitAll() // 新增

                .anyRequest()
    			.authenticated();

解决方案2:使用Spring Security的CorsFilter

// 允许跨域访问
http.cors(); // 激活Spring Security框架内置的一个CorsFilter,允许跨域访问

另外,需要注意:浏览器会对预检结果进行缓存,一旦通过预检,后续的每一次请求将不再执行预检!

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_48206782/article/details/127291638

智能推荐

FTP命令字和返回码_ftp 登录返回230-程序员宅基地

文章浏览阅读3.5k次,点赞2次,收藏13次。为了从FTP服务器下载文件,需要要实现一个简单的FTP客户端。FTP(文件传输协议) 是 TCP/IP 协议组中的应用层协议。FTP协议使用字符串格式命令字,每条命令都是一行字符串,以“\r\n”结尾。客户端发送格式是:命令+空格+参数+"\r\n"的格式服务器返回格式是以:状态码+空格+提示字符串+"\r\n"的格式,代码只要解析状态码就可以了。读写文件需要登陆服务器,特殊用..._ftp 登录返回230

centos7安装rabbitmq3.6.5_centos7 安装rabbitmq3.6.5-程序员宅基地

文章浏览阅读648次。前提:systemctl stop firewalld 关闭防火墙关闭selinux查看getenforce临时关闭setenforce 0永久关闭sed-i'/SELINUX/s/enforcing/disabled/'/etc/selinux/configselinux的三种模式enforcing:强制模式,SELinux 运作中,且已经正确的开始限制..._centos7 安装rabbitmq3.6.5

idea导入android工程,idea怎样导入Android studio 项目?-程序员宅基地

文章浏览阅读5.8k次。满意答案s55f2avsx2017.09.05采纳率:46%等级:12已帮助:5646人新版Android Studio/IntelliJ IDEA可以直接导入eclipse项目,不再推荐使用eclipse导出gradle的方式2启动Android Studio/IntelliJ IDEA,选择 import project3选择eclipse 项目4选择 create project f..._android studio 项目导入idea 看不懂安卓项目

浅谈AI大模型技术:概念、发展和应用_ai大模型应用开发-程序员宅基地

文章浏览阅读860次,点赞2次,收藏6次。AI大模型技术已经在自然语言处理、计算机视觉、多模态交互等领域取得了显著的进展和成果,同时也引发了一系列新的挑战和问题,如数据质量、计算效率、知识可解释性、安全可靠性等。城市运维涉及到多个方面,如交通管理、环境监测、公共安全、社会治理等,它们需要处理和分析大量的多模态数据,如图像、视频、语音、文本等,并根据不同的场景和需求,提供合适的决策和响应。知识搜索有多种形式,如语义搜索、对话搜索、图像搜索、视频搜索等,它们可以根据用户的输入和意图,从海量的数据源中检索出最相关的信息,并以友好的方式呈现给用户。_ai大模型应用开发

非常详细的阻抗测试基础知识_阻抗实部和虚部-程序员宅基地

文章浏览阅读8.2k次,点赞12次,收藏121次。为什么要测量阻抗呢?阻抗能代表什么?阻抗测量的注意事项... ...很多人可能会带着一系列的问题来阅读本文。不管是数字电路工程师还是射频工程师,都在关注各类器件的阻抗,本文非常值得一读。全文13000多字,认真读完大概需要2小时。一、阻抗测试基本概念阻抗定义:阻抗是元器件或电路对周期的交流信号的总的反作用。AC 交流测试信号 (幅度和频率)。包括实部和虚部。​图1 阻抗的定义阻抗是评测电路、元件以及制作元件材料的重要参数。那么什么是阻抗呢?让我们先来看一下阻抗的定义。首先阻抗是一个矢量。通常,阻抗是_阻抗实部和虚部

小学生python游戏编程arcade----基本知识1_arcade语言 like-程序员宅基地

文章浏览阅读955次。前面章节分享试用了pyzero,pygame但随着想增加更丰富的游戏内容,好多还要进行自己编写类,从今天开始解绍一个新的python游戏库arcade模块。通过此次的《连连看》游戏实现,让我对swing的相关知识有了进一步的了解,对java这门语言也有了比以前更深刻的认识。java的一些基本语法,比如数据类型、运算符、程序流程控制和数组等,理解更加透彻。java最核心的核心就是面向对象思想,对于这一个概念,终于悟到了一些。_arcade语言 like

随便推点

安装php_soap.dll,php如何安装soap扩展-程序员宅基地

文章浏览阅读529次。php安装soap扩展的方法:首先打开“php.ini”文件;然后添加代码为“extension = php_soap.dll”;最后修改soap配置项并保存即可。安装 SOAP 扩展对于 Windows 平台,需要在 php.ini 中加入如下代码:extension = php_soap.dll上面的工作完成之后,还需要注意的是 SOAP 扩展在配置文件中有独立的代码片段:[soap]; En..._php_soap.dll

【MybatisPlus 学习】配置多数据源_mybatisplus同一个方法里操作两个数据源-程序员宅基地

文章浏览阅读746次。适用于多种场景:纯粹多库、 读写分离、 一主多从、 混合模式等目前我们就来模拟一个纯粹多库的一个场景,其他场景类似场景说明:我们创建两个库,分别为:mybatis_plus(以前的库不动)与mybatis_plus_1(新建),将mybatis_plus库的product表移动到mybatis_plus_1库,这样每个库一张表,通过一个测试用例分别获取用户数据与商品数据,如果获取到说明多库模拟成功文章目录一、创建数据库及表二、引入依赖三、配置多数据源四、创建用户service五、创建商品serv_mybatisplus同一个方法里操作两个数据源

用Keil仿真查看PWM输出_keil5如何仿真pwm输出-程序员宅基地

文章浏览阅读1.1w次,点赞13次,收藏62次。1.配置调试工具2.打开调试, 进入调试界面后 ,打开logic analysis窗口,并设置PWM输出引脚3.点击全速运行,观察示波器_keil5如何仿真pwm输出

【基基基础】什么?你连软件都还没下载?!手把手教你下载VS/Dev-C++,写出属于自己的第一个C语言代码_vs dev-c++-程序员宅基地

文章浏览阅读446次,点赞8次,收藏6次。如果你是一个新手小小小小白,想要学习C语言,却被困在了第一步,那么你一定要看o(╥﹏╥)o。_vs dev-c++

口述历史:Andrew Viterbi-程序员宅基地

文章浏览阅读353次。IEEE对于维特比(高通公司联合创始人)的访谈_andrew viterbi

【增强版短视频去水印源码】去水印微信小程序+去水印软件源码_去水印机要增强版-程序员宅基地

文章浏览阅读1.1k次。源码简介与安装说明:2021增强版短视频去水印源码 去水印微信小程序源码网站 去水印软件源码安装环境(需要材料):备案域名–服务器安装宝塔-安装 Nginx 或者 Apachephp5.6 以上-安装 sg11 插件小程序已自带解析接口,支持全网主流短视频平台,搭建好了就能用注:接口是公益的,那么多人用解析慢是肯定的,前段和后端源码已经打包,上传服务器之后在配置文件修改数据库密码。然后输入自己的域名,进入后台,创建小程序,输入自己的小程序配置即可安装说明:上传源码,修改data/_去水印机要增强版

推荐文章

热门文章

相关标签