Spring Security框架个人复习

关于Spring Security框架

Spring Security框架主要解决了认证授权相关的问题。

1. 添加Spring Boot Security依赖

csmall-passport项目中添加依赖项:

<!-- Spring Boot Security的依赖项,用于处理认证与授权 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

当添加了以上依赖项后,当前项目会:

  • 此依赖项中包含BCryptPasswordEncoder类,可以用于处理密码加密
  • 所有请求都是必须通过认证的,在没有通过认证之前,任何请求都会被重定向到Spring Security内置的登录页面
    • 可以使用user作为用户名,使用启动项目时随机生成的UUID密码来登录
    • 当登录成功后,会自动重定向到此前尝试访问的页面
    • 当登录成功后,所有GET的异步请求允许访问,但POST的异步请求不允许访问(403错误)

当添加依赖后,在浏览器中尝试访问时还可能出现以下错误:

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the header value "Idea-c968a669=03021799-4633-4321-9d0d-11b7ee08f588; username=é»æ±å; JSESSIONID=120F9329E0CE7AF9E052A302EFE494F2" is not allowed.

此错误是浏览器的问题导致的,更换浏览器即可。

2. 关于BCryptPasswordEncoder

BCrypt算法是用于对密码进行加密处理的,在spring-boot-starter-security中包含了BCryptPasswordEncoder,可以实现编码、验证:

public class BCryptTests {

    @Test
    public void encode() {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        String rawPassword = "123456";
        System.out.println("原文:" + rawPassword);

        for (int i = 0; i < 50; i++) {
            String encodedPassword = passwordEncoder.encode(rawPassword);
            System.out.println("密文:" + encodedPassword);
        }
//        密文:$2a$10$H7neseWrkpdCQiW6R4bJyeXaU.nowsFZZz.iO4HCLzFScz.FdpDSG
//        密文:$2a$10$DoQQSh9eAxDRVKADzQ.Q8Oa4QqcpMUR9UmKyptop3i0mwsdfS.wyC
//        密文:$2a$10$tZCa3YIYehg5B9VESrDOWeoBAX3aX4f.Ioc4awtiY/vwihGmD.xQG
//        密文:$2a$10$9qx53wQEF0XjSjKattwEw.mFayMvjxLnZmPnRO5V1DnZvKuCLrVQG
//        密文:$2a$10$dmGQK7iwTd9Mbwa/mxzABeBHezbqyGpqwmxUobwelQDlRuW4oHS9e
    }

    @Test
    public void matches() {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        String rawPassword = "123456";
        String encodedPassword = "$2a$10$H7neseWrkpdCQiW6R4bJyeXaU.nowsFZZz.iO4HCLzFScz.FdpDSG";

        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("原文:" + rawPassword);
        System.out.println("密文:" + encodedPassword);
        System.out.println("验证:" + matches);
    }

}

BCrypt算法默认使用了随机盐值,所以,即使使用相同的原文,每次编码产生的密文都是不同的!

BCrypt算法被刻意设计为慢速的,所以,可以非常有限的避免穷举式的暴力破解!

3. 关于Spring Security的配置类

在Spring Boot项目中,在根包下创建config.SecurityConfiguration类,作为Spring Security的配置类,需要继承自WebSecurityConfigurerAdapter类,并重写其中的方法进行配置:

package cn.tedu.csmall.passport.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/**
 * Spring Security配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    public SecurityConfiguration() {
        log.debug("创建配置类对象:SecurityConfiguration");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 如果不调用父类方法,默认所有请求都不需要通过认证,可以直接访问
        // super.configure(http);

        // 白名单
        String[] urls = {
                "/favicon.ico",
                "/doc.html",
                "/**/*.js",
                "/**/*.css",
                "/swagger-resources",
                "/v2/api-docs"
        };

        // 将防止伪造跨域攻击的机制禁用
        http.csrf().disable();

        // 提示:关于请求路径的配置,如果同一路径对应多项配置规则,以第1次配置的为准
        http.authorizeRequests() // 管理请求授权
                .mvcMatchers(urls) // 匹配某些路径
                .permitAll() // 直接许可,即可不需要通过认证即可访问
                .anyRequest() // 除了以上配置过的以外的其它所有请求
                .authenticated(); // 要求是“已经通过认证的”

        // 启用登录表单
        // 当未认证时:
        // -- 如果启用了表单,会自动重定向到登录表单
        // -- 如果未启用表单,则会提示403错误
        http.formLogin();
    }

}

4. 关于伪造的跨域攻击

伪造的跨域攻击(CSRF)主要是基于服务器端对浏览器的信任,在多选项卡的浏览器中,如果在X选项卡中登录,在Y选项卡中的访问也会被视为“已登录”。

在Spring Security框架中,默认开启了“防止伪造的跨域攻击”的机制,其基本做法就是在POST请求中,要求客户端提交其随机生成的一个UUID值,例如,(在没有禁用防止伪造跨域攻击时)在Spring Security的登录页面中有:

<input name="_csrf" type="hidden" value="b6dc65f8-e0cf-4907-bdaf-a5f19b759f93" />

以上代码中的value值就是一个UUID值,是前次GET请求时由服务器端响应的,服务器端会要求客户端携带此UUID来访问,否则,就会将请求视为伪造的跨域攻击行为!

关于登录账号

默认情况下,Spring Security框架提供了默认的用户名user和启动时随机生成UUID密码,如果需要自定义登录账号,可以自定义类,实现UserDetailsService接口,重写接口中的如下方法:

UserDetails loadUserByUsername(String username);

Spring Security框架在处理认证时,会自动根据提交的用户名(用户在登录表单中输入的用户名)来调用以上方法,以上方法应该返回匹配的用户详情(UserDetails类型的对象),接下来,Spring Security会自动根据用户详情(UserDetails对象)来完成认证过程,例如判断密码是否正确等。

可以在根包下创建security.UserDetailsServiceImpl类,在类上添加@Service注解,实现UserDetailsService接口,重写接口中定义的抽象方法:

package cn.tedu.csmall.passport.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
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;

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
        // 假设正确的用户名 / 密码分别是 root / 1234
        if ("root".equals(s)) {
            UserDetails userDetails = User.builder()
                    .username("root")
                    .password("1234")
                    .disabled(false)
                    .accountLocked(false) // 此项目未设计“账号锁定”的机制,固定为false
                    .accountExpired(false) // 此项目未设计“账号过期”的机制,固定为false
                    .credentialsExpired(false) // 此项目未设计“凭证锁定”的机制,固定为false
                    .authorities("暂时给出的假的权限标识") // 权限
                    .build();
            return userDetails;
        }
        return null;
    }

}

完成后,重启项目,首先,可以在启动日志中看到,Spring Security框架不再生成随机的UUID密码。

在Spring Security处理认证时,还会自动装配Spring容器中的密码编码器(PasswordEncoder),如果Spring容器中并没有密码编码器,则无法验证密码是否正确,当使用了正确的用户名尝试登录时,服务器端将报告错误:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

可以在SecurityConfiguration中添加@Bean方法,来配置所需的密码编码器:

@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

提示:以上使用的密码编码器是无操作的密码编码器(No Operation),不会对密码进行加密处理,是不推荐的,所以,此类被声明为已过期,在IntelliJ IDEA中,此类的名称会有删除线。

当添加了密码编码器后,再次启用项目,尝试登录:

  • 当用户名错误时,会提示UserDetailsService返回了是null
  • 当用户名正确,但密码错误时,会提示登录失败
  • 当用户名、密码均正确时,将成功登录

也可以将以上NoOpPasswordEncoder换成BCryptPasswordEncoder,例如:

@Bean
public PasswordEncoder passwordEncoder() {
    // return NoOpPasswordEncoder.getInstance();
    return new BCryptPasswordEncoder();
}

如果修改,则UserDetails对象中封装的密码也必须是与此密码编码器符合的,即必须是BCrypt算法加密的结果,例如:

使用前后端分离的登录模式

目前的登录是由Spring Security提供了登录表单,然后由自定义的UserDetailsServiceImpl获取对应的用户信息,并由Spring Security完后后续的认证过程,以此来实现的,这不是前后端分离的开发模式,因为依赖于Spring Security提供的登录表单,例如csmall-web-client或其它客户端根本没有办法像服务器端发送登录请求!

要实现前后端分离的登录模式,需要:

  • 使用控制器接收来自客户端的登录请求
    • 创建AdminLoginDTO封装客户端提交的用户名、密码
    • 所设计的登录请求的URL必须添加到“白名单”
  • 使用Service处理登录认证
    • 调用AuthenticationManagerauthenticate()方法处理认证
      • 可以通过重写配置类中的authenticationManagerBean()方法,并添加@Bean注解来得到
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
    // 假设正确的用户名 / 密码分别是 root / 1234
    if ("root".equals(s)) {
        UserDetails userDetails = User.builder()
                .username("root")
                .password("$2a$10$DoQQSh9eAxDRVKADzQ.Q8Oa4QqcpMUR9UmKyptop3i0mwsdfS.wyC")
                
            	// 后续代码没有调整……

【SecurityConfiguration】

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

关于“登录”的判断标准

在Spring Security框架中,对于“登录”(通过认证)的判断标准是:在SecurityContext(Security上下文)中是否存在Authentication对象(认证信息),如果存在,Spring Security框架会根据Authentication对象识别用户的身份、权限等,如果不存在,则视为“未登录”。

在默认情况下,Spring Security框架也是基于Session来处理(读写)用户的信息的。

 关于Session

HTTP协议本身是无状态协议,无法保存用户信息,即:某客户端第1次访问了服务器端,可能产生了某些数据,此客户端再次访问服务器端时,服务器端无法识别出这个客户端是此前曾经来访的客户端。

为了能够识别客户端的身份,当某客户端第1次向服务器端发起请求时,服务器端将向客户端响应一个JSESSIONID数据,其本质是一个UUID数据,在客户端后续的访问中,客户端会自动携带此JSESSIONID,以至于服务器端能够识别此客户端的身份。同时,在服务器端,还是一个Map结构的数据,此数据是使用JSESSIONID作为Key的,所以,每个客户端在服务器端都有一个与之对应在的在此Map中的Value,也就是Session数据!

提示:UUID是全球唯一的,从设计上,它能够保证在同一时空中的唯一性。

由于Session的运作机制,决定了它必然存在缺点:

  • 默认不适用于集群或分布式系统,因为Session是内存中的数据,所以,默认情况下,Session只存在于与客户端交互的那台服务器上,如果使用了集群,客户端每次请求的服务器都不是同一台服务器,则无法有效的识别客户端的身份
    • 可以通过共享Session等机制解决
  • 不适合长时间保存数据,因为Session是内存中的数据,并且,所有来访的客户端在服务器端都有对应的Session数据,就必须存在Session清除机制,如果长期不清除,随着来访的客户端越来越多,将占用越来越多的内存,服务器将无法存储这大量的数据,通常,会将Session设置为15分钟或最多30分钟清除

Token

Token:票据、令牌

由于客户端种类越来越多,目前,主流的识别用户身份的做法都是使用Token机制,Token可以理解为“票据”,例如现实生活中的“火车票”,某客户端第1次请求服务器,或执行登录请求,则可视为“购买火车票”的行为,当客户端成功登录,相当于成功购买了火车票,客户端的后续访问应该携带Token,相当于乘坐火车需要携带购票凭证,则服务器端可以识别客户端的身份,相当于火车站及工作人员可以识别携带了购买凭证的乘车人。

与Session最大的区别在于:Token是包含可识别的有效信息的!对于需要获取信息的一方而言,只需要具备读取Token信息的能力即可。

Session机制中客户端需要携带的JSESSIONID本身上是UUID,此数据只具有唯一性,并不是有意义的数据,真正有意义的数据是服务器端内存中的Session数据。

所以,Token并不需要占用较多的内存空间,是可以长时间,甚至非常长时间保存用户信息的!

 JWT

JWTJSON 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的示例代码:

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 {

    String secretKey = "a9F8ujGFDhjgvfd3SA90ukEDS";

    @Test
    public void generate() {
        Date date = new Date(System.currentTimeMillis() + 5 * 60 * 1000);

        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("username", "liucangsong");

        String jwt = Jwts.builder()
                // Header
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload
                .setClaims(claims)
                // Signature
                .setExpiration(date)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        System.out.println(jwt);

        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc3ODg5LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.Txpj_kcLpkpUoEYA94pLCM3H807UnOEqN_r0c005I44
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5MDM2LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.gMlHQiSbbWnf5cIBi0p4V9bz05QHRaq3rNC8e_4yfpE
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ
    }

    @Test
    public void parse() {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ";

        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();

        Long id = claims.get("id", Long.class);
        String username = claims.get("username", String.class);

        System.out.println("id = " + id);
        System.out.println("username = " + username);
    }

}

当尝试解析JWT时,可能会出现以下错误:

  • 如果JWT已过期,会抛出ExpiredJwtException,例如:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-11-08T11:24:49Z. Current time: 2022-11-08T11:38:01Z, a difference of 792152 milliseconds.  Allowed clock skew: 0 milliseconds.
  • 如果JWT数据有误,会抛出MalformedJwtException,例如:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg":"HS7#�$�uB'
  • 如果JWT签名不匹配,会抛出SignatureException,例如:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

 登录成功时返回JWT

在处理登录时,当用户登录成功,应该向客户端返回JWT数据,以至于客户端下次提交请求时,可以携带JWT来访问服务器端!

首先,需要在通过认证(登录成功)后,生成JWT数据,并返回!在Spring Security框架中,AuthenticationManager调用authenticate()方法时,如果通过认证,会返回Authentication接口类型的对象,本质上是UsernamePasswordAuthenticationToken类型,此类型中的pricipal属性就是通过认证的用户信息,也是UserDetailsService中的loadUserByUsername()方法返回的结果,例如:

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=[暂时给出的假的权限标识]
]

所以,可以在处理认证的代码后再添加读取认证结果、生成JWT的代码:

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

    // 从认证结果中获取所需的数据,将用于生成JWT
    Object principal = authenticateResult.getPrincipal();
    log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
    User user = (User) principal;
    String username = user.getUsername();

    // 生成JWT数据时,需要填充装到JWT中的数据
    Map<String, Object> claims = new HashMap<>();
    // claims.put("id", 9527);
    claims.put("username", username);
    // 以下是生成JWT的固定代码
    String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
    Date date = new Date(System.currentTimeMillis() + 5 * 24 * 60 * 60 * 1000L);
    String jwt = Jwts.builder()
            // Header
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            // Payload
            .setClaims(claims)
            // Signature
            .setExpiration(date)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成的JWT:{}", jwt);
}

接下来,需要将IAdminService接口中定义的“登录”方法的返回值类型修改为String

/**
 * 管理员登录
 *
 * @param adminLoginDTO 封装了管理员的用户名和密码的对象
 * @return 登录成功后生成的匹配的JWT
 */
String login(AdminLoginDTO adminLoginDTO);

并且修改其实现,并返回JWT。

然后,调整控制器中处理登录请求的方法:

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

完成后,重启项目,在API文档中调试,使用正确的用户名、密码登录,响应结果中将包含对应的JWT数据,并且,此JWT数据可以在此前编写的测试方法中尝试解析(注意:务必保证生成JWT和解析JWT使用的secretKey是相同的)。

识别客户端的身份

基于Spring Security框架的特征“依据SecurityContext中的认证信息来判定当前是否已经通过认证”,所以,客户端应该在得到JWT之后,携带JWT向服务器端提交请求,而服务器端应该尝试解析此JWT,并且从中获取用户信息,用于创建认证对象,最后,将认证对象存入到SecurityContext中,剩下的就可以交由框架进行处理了,例如判断是否已经通过认证等。

由于若干个不同的请求都需要识别客户端的身份(即解析JWT、创建认证对象、将认证对象存入到SecurityContext),所以,应该通过能够统一处理的组件来处理JWT,同时,此项任务必须在Spring Security的过滤器之前执行,则此项任务只能通过自定义过滤器来处理!

则在项目的根包下创建filter.JwtAuthorizationFilter类:

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

/**
 * <p>JWT过滤器</p>
 *
 * <p>此JWT的主要作用:</p>
 * <ul>
 *     <li>获取客户端携带的JWT,惯用做法是:客户端应该通过请求头中的Authorization属性来携带JWT</li>
 *     <li>解析客户端携带的JWT,并创建出Authentication对象,存入到SecurityContext中</li>
 * </ul>
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public static final int JWT_MIN_LENGTH = 113;

    public JwtAuthorizationFilter() {
        log.info("创建过滤器对象: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() < JWT_MIN_LENGTH) {
            // 对于无效的JWT,直接放行,交由后续的组件进行处理
            log.debug("获取到的JWT被视为无效,当前过滤器将放行……");
            filterChain.doFilter(request, response);
            return;
        }

        // 尝试解析JWT
        log.debug("获取到的JWT被视为有效,准备解析JWT……");
        String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();

        // 获取JWT中的管理员信息
        String username = claims.get("username", String.class);

        // 处理权限信息
        List<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
        authorities.add(authority);

        // 创建Authentication对象
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(
                        username, null, authorities);

        // 将Authentication对象存入到SecurityContext
        log.debug("向SecurityContext中存入认证信息:{}", authentication);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 过滤器链继续向后传递,即:放行
        log.debug("JWT过滤器执行完毕,放行!");
        filterChain.doFilter(request, response);
    }

}

并且,为了保证此过滤器在Spring Security的过滤器之前执行,还应该在SecurityConfiguration中,先自动装配此过滤器对象:

@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

然后,在void configurer(HttpSecurity http)方法中补充配置:

// 将JWT过滤器添加到Spring Security框架的过滤器链中
http.addFilterBefore(jwtAuthorizationFilter, 
                     	UsernamePasswordAuthenticationFilter.class);

至此,简单的登录处理已经完成,客户端或API文档可以通过 /admins/login 登录,以获取JWT数据,并且,在后续的访问中,如果携带了JWT数据,将可以正常访问,否则,将无权访问!

目前,还存在需要解决的问题:

  • 生成和解析JWT的secretKey不应该分别定义在2个类中
  • 解析JWT可能出现异常,但尚未处理
  • 认证信息中的“当事人”是使用username表示的,不包含此管理员的id,不便于实现后续的需求
  • 认证信息中权限目前是假数据
  • 前端还没有结合起来

 前端登录

在前端的登录页面中,当服务器端响应登录成功后,应该将服务器端响应的JWT数据保存下来,可以使用localStorage来保存数据,例如:

  let url = 'http://localhost:9081/admins/login';
  console.log('url = ' + url);
  let formData = this.qs.stringify(this.ruleForm);
  console.log('formData = ' + formData);
  this.axios.post(url, formData).then((response) => {
    let responseBody = response.data;
    if (responseBody.state == 20000) {
      this.$message({
        message: '登录成功!',
        type: 'success'
      });
      let jwt = responseBody.data;
      console.log('登录成功,服务器端响应JWT:' + jwt);
      localStorage.setItem('jwt', jwt);  // 使用localStorage保存数据
      console.log('已经将JWT保存到localStorage');
    } else {
      console.log(responseBody.message);
      this.$message.error(responseBody.message);
    }
  });

后续,当需要此数据时,可以通过localStorage.getItem(key)来获取此前存入的数据。

在需要携带JWT的请求中,可以调用axios对象的create()方法来配置请求头,并使用此方法返回的axios对象向服务器端提交请求,例如:

  loadAdminList() {
  console.log('loadAdminList');
  console.log('在localStorage中的JWT数据:' + localStorage.getItem('jwt'));
  let url = 'http://localhost:9081/admins';
  console.log('url = ' + url);
  this.axios
      // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  以下是携带JWT提交请求的关键代码  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
      .create({'headers': {'Authorization': localStorage.getItem('jwt')}})
      .get(url).then((response) => {
    let responseBody = response.data;
    this.tableData = responseBody.data;
  });
}

需要注意:当客户端的异步请求定义了请求头中的Authorization时,在服务器端,在SecurityConfiguration类的void configurer(HttpSecurity http)方法中,需要添加以下配置:

http.cors();

否则客户端将出现跨域错误!

关于复杂请求的PreFlight

PreFight:预检

当客户端提交的请求自定义了请求头,且请求头中的属性不是常规属性时(例如Authorization就不是常规属性),这类请求会被视为复杂请求,就会触发预检(PreFlight)机制,浏览器会自动向对应的URL提交一个OPTIONS类型的请求,如果此请求被正常响应(即HTTP响应码为200),才可以正常提交原本的请求,否则,视为预检失败,会提示跨域错误。

需要注意:预检是基于浏览器缓存的,如果某个请求对应的URL曾经预检通过,则后续再次提交请求时不会执行预检!

在服务器端的SecurityConfiguration中,在重写的void configurer(HttpSecurity http)方法中,配置请求认证时,对所有OPTIONS请求直接放行,即可解决预检不通过导致的跨域错误,例如:

http.authorizeRequests()

    // ↓↓↓↓↓ 对所有OPTIONS请求直接放行 ↓↓↓↓↓
    .mvcMatchers(HttpMethod.OPTIONS, "/**")
    .permitAll()

    .mvcMatchers(urls)
    .permitAll()
    .anyRequest()
    .authenticated();

或者,更简单一点,直接调用参数httpcors()方法,则Spring Security会自动启用一个CorsFilter,这是Spring Security专门用于处理跨域问题的过滤器,也会对OPTIONS请求放行,所以,实现的效果是完全相同的!

注意:以上解决方案并不能取代目前使用WebMvcConfiguration解决的跨域问题!

使用配置文件自定义JWT参数

生成和解析JWT都需要使用到secretKey,并且,这2处使用到的secretKey值必须是完全相同的!所以,应该使用一个公共的位置来配置secretKey的值,由于此值应该允许被客户(软件的使用者)修改,则应该将此值定义的配置文件中(不推荐定义在某个类)。

则可以在application-dev.yml中添加自定义配置:

# 当前项目中的自定义配置
csmall:
  # JWT相关配置
  jwt:
    # 生成和解析JWT时使用的secretKey
    secret-key: a9F8ujGDhjgFvfEd3SA90ukDS
    # JWT的有效时长,以分钟为单位
    duration-in-minute: 14400

提示:当在.yml.properties中添加配置后(无论是否为自定义配置),当加载时,会将这些配置读取到Spring框架内置的Environment对象中,另外,操作系统的配置和JVM配置也会自动读取到Environment中,且配置文件中的配置的优先级是最低的(会被覆盖),使用@Value读取值,其实是从Environment中读取的,并不是直接从配置文件中读取的!

添加配置后,在生成JWT时使用,即在AdminServiceImpl中声明2个全局属性,通过@Value注解为这2个属性注入配置的值:

@Value("${csmall.jwt.secret-key}")
private String secretKey;
@Value("${csmall.jwt.duration-in-minute}")
private long durationInMinute;

在生成JWT时,就可以直接使用这2个属性了!

另外,在JwtAuthorizationFilter也应该使用同样的做法,应用secretKey的配置!

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

当前项目中使用过滤器解析JWT,而过滤器是JAVA EE项目中最早接收到请求的组件,此时其它组件(例如Controller)均未开始处理此请求,所以,如果过滤器在解析JWT时出现异常,Controller是无法“知晓”的,则全局异常处理器也无法处理这些异常,只能在过滤器中使用try...catch语法处理。

处理异常后的响应应该是JSON格式的,当前项目中一直在使用JsonResult表示响应的结果,但是,由于过滤器解析JWT时,Spring MVC的相关组件尚未运行,无法自动将JsonResult对象转换成JSON格式的字符串,所以,需要先在项目中添加依赖项,用于将对象转换成JSON格式的字符串:

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

然后,在ServiceCode中添加新的枚举值:

public enum ServiceCode {

    // 此前已有的枚举值
    
    // ↓↓↓↓↓  新增的枚举值  ↓↓↓↓↓
    /**
     * 错误:JWT签名错误
     */
    ERR_JWT_SIGNATURE(60000),
    /**
     * 错误:JWT数据格式错误
     */
    ERR_JWT_MALFORMED(60100),
    /**
     * 错误:JWT已过期
     */
    ERR_JWT_EXPIRED(60200);
    
    // 其它代码

最后,在JwtAuthorizationFilter中,使用try...catch包裹解析JWT的代码,并处理相关异常:

// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
response.setContentType("application/json; charset=utf-8");
Claims claims = null;
try {
    claims = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(jwt)
            .getBody();
} catch (SignatureException e) {
    log.debug("解析JWT时出现SignatureException");
    String message = "非法访问!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_SIGNATURE, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (MalformedJwtException e) {
    log.debug("解析JWT时出现MalformedJwtException");
    String message = "非法访问!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_MALFORMED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (ExpiredJwtException e) {
    log.debug("解析JWT时出现ExpiredJwtException");
    String message = "登录信息已过期,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_EXPIRED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (Throwable e) {
    log.debug("解析JWT时出现Throwable,需要开发人员在JWT过滤器补充对异常的处理");
    e.printStackTrace();
    String message = "你有异常没有处理,请根据服务器端控制台的信息,补充对此类异常的处理!!!";
    PrintWriter writer = response.getWriter();
    writer.println(message);
    return;
}

将登录的管理员的id封装到认证信息中

在根包下创建security.AdminDetails类,继承自User类,并在类中扩展声明Long id属性:

package cn.tedu.csmall.passport.security;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

/**
 * 管理员详情类,是Spring Security框架的loadUserByUsername()的返回结果
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Setter
@Getter
@ToString(callSuper = true)
@EqualsAndHashCode
public class AdminDetails extends User {

    private Long id;

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

}

UserDetailsService中,当需要返回UserDetails对象时,返回以上自定义的对象:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
        AdminLoginInfoVO admin = adminMapper.getLoginInfoByUsername(s);
        log.debug("从数据库中根据用户名【{}】查询管理员信息,结果:{}", s, admin);
        if (admin == null) {
            log.debug("没有与用户名【{}】匹配的管理员信息,即将抛出BadCredentialsException", s);
            String message = "登录失败,用户名不存在!";
            throw new BadCredentialsException(message);
        }

        List<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
        authorities.add(authority);

        AdminDetails adminDetails = new AdminDetails(
                admin.getId(),
                admin.getUsername(),
                admin.getPassword(),
                admin.getEnable() == 1,
                authorities);
        log.debug("即将向Spring Security框架返回UserDetails对象:{}", adminDetails);
        return adminDetails;
    }

}

至此,当用户成功登录后,AuthenticationManagerauthenticate()返回的认证信息中的当事人(Principal)就是以上返回的AdminDetails,其中是包含id和用户名等信息的!在处理认证后,可以得到这些信息,并用于生成JWT。

AdminServiceImpllogin()方法:

// 从认证结果中获取所需的数据,将用于生成JWT
Object principal = authenticateResult.getPrincipal();
log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
AdminDetails adminDetails = (AdminDetails) principal;
String username = adminDetails.getUsername();
Long id = adminDetails.getId(); // 新增

// 生成JWT数据时,需要填充装到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", id);  // 新增
claims.put("username", username);

至此,当用户成功登录后,得到的JWT中是包含了idusername的!

要将idusername同时封装到认证信息中,由于认证信息中的当事人只是1个数据,如果要将idusername这2个数据都封装进去,就需要自定义类,在类定义这2个属性,此类的对象将是最终存入到认证信息中的当事人:

package cn.tedu.csmall.passport.security;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginPrincipal implements Serializable {

    private Long id;
    private String username;

}

JwtAuthorizationFilter中:

// 获取JWT中的管理员信息
String username = claims.get("username", String.class);
Long id = claims.get("id", Long.class); // 新增

// 处理权限信息
// 省略相关代码

// 创建Authentication对象
LoginPrincipal loginPrincipal = new LoginPrincipal(id, username);  // 新增
Authentication authentication
        = new UsernamePasswordAuthenticationToken(
            loginPrincipal, null, authorities);
//          ↑↑↑↑↑ 调整 ↑↑↑↑↑

// 将Authentication对象存入到SecurityContext
log.debug("向SecurityContext中存入认证信息:{}", authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);

 在处理请求时识别当前登录的用户身份

在任何处理请求的方法的参数列表中,都可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal参数,Spring Security框架会自动从上下文(SecurityContext)中获取认证信息中的当事人,作为此参数的值!所以,在处理请求时,可以知晓当前登录的用户的idusername,例如:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
    	// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  新增  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("开始处理【查询管理员列表】的请求,无参数");
    log.debug("当前登录的当事人:{}", loginPrincipal);
    List<AdminListItemVO> list = adminService.list();
    return JsonResult.ok(list);
}

提示:以上@ApiIgnore注解用于避免API文档中提示要求客户端提交idusername

处理权限

需要在处理认证(登录)时,根据用户名查询管理员详情时,一并查询出此管理员的权限信息,需要执行的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='wangkejing';

要保证执行认证时能够查询到管理员的基本信息和权限,需要:

  • AdminLoginInfoVO中添加属性,表示此管理员的权限
  • AdminMapper.java接口中的抽象方法不必调整
  • AdminMapper.xml中调整SQL语句,及如何封装查询结果

先在AdminLoginInfoVO中添加:

/**
 * 权限列表
 */
private List<String> permissions;

然后在AdminMapper.xml中调整配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<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>

<!-- 在1对多的查询中,List属性需要使用collection标签来配置 -->
<!-- collection标签的property属性:封装查询结果的类型中的属性名,即List的属性名 -->
<!-- collection标签的ofType属性:List的元素数据类型,取值为类型的全限定名 -->
<!-- collection标签的子级:如何将查询结果中的数据封装成ofType类型的对象 -->
<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="String">
        <constructor>
            <arg column="value"/>
        </constructor>
    </collection>
</resultMap>

接下来,应该将管理员的权限存入到SecurityContext中!需要:

  • UserDetailsServiceImpl中返回的对象中需要包含真实的权限信息
  • AdminServiceImpl中,认证通过后,从返回的结果中获取权限信息,并将其转换为JSON格式的字符串,存入到JWT中
  • JwtAuthorizationFilter中,解析JWT成功后,获取权限信息对应的JSON字符串,并将其反序列化为Collection<? extends GrantedAuthority>格式,并存入到Authentication中,进而存入到SecurityContext

以上全部完成后,就可以开始配置权限了!需要先在配置类(强烈建议SecurityConfiguration)上添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,以启用方法级别的权限检查!然后,可以选择将配置检查的配置添加在控制器中处理请求的方法上(其实也可以添加在其它组件的自定义方法上),例如:

@ApiOperation("添加管理员")
@ApiOperationSupport(order = 100)
@PreAuthorize("hasAuthority('/ams/admin/add-new')") // 新增
@PostMapping("/add-new")
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
    log.debug("开始处理【添加管理员】的请求,参数:{}", adminAddNewDTO);
    adminService.addNew(adminAddNewDTO);
    return JsonResult.ok();
}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiYXV0aG9yaXRpZXNKc29uU3RyaW5nIjoiW3tcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvYW1zL2FkbWluL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvYW1zL2FkbWluL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYWxidW0vYWRkLW5ld1wifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYWxidW0vZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2JyYW5kL2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2JyYW5kL2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvcmVhZFwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9jYXRlZ29yeS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9jYXRlZ29yeS9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcGljdHVyZS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcGljdHVyZS9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcHJvZHVjdC9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcHJvZHVjdC9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3VwZGF0ZVwifV0iLCJleHAiOjE2Njg4NTE1MjgsInVzZXJuYW1lIjoicm9vdCJ9.PZw1dwiPP7uDgGf-GQsPdahmLmq1oLC3tPsP0M2K0bM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiYXV0aG9yaXRpZXNKc29uU3RyaW5nIjoiW3tcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2FsYnVtL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2FsYnVtL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvYWRkLW5ld1wifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9icmFuZC9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9icmFuZC91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvY2F0ZWdvcnkvcmVhZFwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvY2F0ZWdvcnkvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3BpY3R1cmUvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3BpY3R1cmUvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvdXBkYXRlXCJ9XSIsImV4cCI6MTY2ODg1MTYxNywidXNlcm5hbWUiOiJzdXBlcl9hZG1pbiJ9.J7NUoItrFePJpmcV8FLl4Pcb1IcAca31nczMfOdiN74

单点登录(SSO)

SSOSingle Sign On,单点登录,表现为客户端只需要在某1个服务器上通过认证,其它服务器也可以识别此客户端的身份!

单点登录的实现手段主要有2种:

  • 使用Session机制,并共享Session

    • spring-boot-starter-data-redis结合spring-session-data-redis
  • 使用Token机制

    • 各服务器需要有同样的解析JWT的代码

当前,csmall-passport中已经使用JWT,则可以在csmall-product项目中也添加Spring Security框架和解析JWT的代码,则csmall-product项目也可以识别用户的身份、检查权限。

需要做的事:

  • 添加依赖

    • spring-boot-starter-security
    • jjwt
    • fastjson
  • 复制ServiceCode覆盖csmall-product原本的文件

  • 复制GlobalExceptionHandler覆盖csmall-product原本的文件

  • 复制application-dev.yml中关于JWT的secretKey的配置

    • 关于JWT有效时长的配置,可以复制,但暂时用不上
  • 复制LoginPrincipalcsmall-product中,与csmall-passport相同的位置

  • 复制JwtAuthorizationFilter

  • 复制SecurityConfiguration

    • 删除PasswordEncoder@Bean方法
    • 删除AuthenticationManager@Bean方法
    • 应该删除白名单中的 /admins/login

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值