Spring Security权限认证实战


Talk is cheap, show me you code. 完整工程代码,GitHub仓库直达

一、基本介绍

先通过下面这张图确定我们需实现的目标,主要分为两条主线: 登录验证权限认证

1. 登录验证

通过 JWT 为每个用户生成唯一且指定期限的 Token ,每次用户请求都将会重新生成重置过期时间,当在指定时间内该用户没有进行操作时 Token 将会过期。此时用户再次请求将会重定向至登录流程,至于 Token 过期时间应该根据业务场景而定。

顺便一提,如果需要实现 N 天免登录可以在接口额外添加一个标记用于表明用户是否勾选了 N 天免登录,然后在生成 Token 即可延迟过期时效。

2. 权限认证

权限认证通过 Spring Security 框架实现,当用户登录之后进行资源访问,对于服务端而言即接口调用,根据用户的角色判断是否有权限进行访问,若没有则应该予以相应的提示。

二、数据准备

1. 用户表

新建角色用户数据表 auth_user,用户存储用户的角色详情,建表语句如下:

CREATE TABLE `auth_user`
(
    `id`                      varchar(36) NOT NULL,
    `username`                varchar(100) DEFAULT NULL,
    `password`                varchar(100) DEFAULT NULL,
    `role`                    varchar(100) DEFAULT NULL,
    `account_non_expired`     int(11) DEFAULT '0',
    `account_non_locked`      int(11) DEFAULT '0',
    `credentials_non_expired` int(11) DEFAULT '0',
    `is_enabled`              int(11) DEFAULT NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf32;

2. 测试数据

在新建的 auth_user 数据表中插入两条测试数据,角色分别为 USERADMIN ,其中密码是 AES 加密后的数据,其对应的明文为: 123

INSERT INTO auth_user (id, username, password, `role`, account_non_expired, account_non_locked,
                       credentials_non_expired, is_enabled)
VALUES ('1', 'user', '15tT+y0b+lJq2HIKUjsvvg==', 'USER', 1, 1, 1, 1),
       ('2', 'admin', '15tT+y0b+lJq2HIKUjsvvg==', 'ADMIN', 1, 1, 1, 1);

三、登录实现

1. 工程依赖

Maven 工程中引入下列依赖,除了 Security 之外还引入了 AESJWT 相关的依赖。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- AES加密 -->
    <dependency>
        <groupId>org.apache.directory.studio</groupId>
        <artifactId>org.apache.commons.codec</artifactId>
        <version>1.8</version>
    </dependency>
    <!-- JWT -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
</dependencies>

2. 实体类

在工程中新建实体类 AuthUser,需要实现 UserDetails 类并重写其 getAuthorities() 方法读取数据库中配置的角色权限。

还有一个非常重要的点是一定要重写 isAccountNonExpired()isAccountNonLocked()isCredentialsNonExpired()isEnabled() 四个方法且返回 true ,这样角色才处于正常激活状态,这里我配置的是读取数据库查询出来的结果。

public class AuthUser implements Serializable, UserDetails {

    private static final long serialVersionUID = 1L;

    private String id;

    private String username;

    private String password;

    private String role;

    private Integer accountNonExpired;

    private Integer accountNonLocked;

    private Integer credentialsNonExpired;

    private Integer isEnabled;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 获取用户所有权限
        String[] roles = role.split(",");
        // 遍历 roles,取出每一个权限进行认证,添加到简单的授予认证类
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
        }
        // 返回到已经被授予认证的权限集合, 这里面的角色所拥有的权限都已经被 spring security 所知道
        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired != null && this.accountNonExpired == 1;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked != null && this.accountNonLocked == 1;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNonExpired != null && this.credentialsNonExpired == 1;
    }

    @Override
    public boolean isEnabled() {
        return this.isEnabled != null && this.isEnabled == 1;
    }

    // 略去其它 Get、Set 方法
}

3. Service

完成用户实体创建之后新建 AuthUserService 接口,其需要继承 UserDetailsService 类,然后在实现类中重写 loadUserByUsername() 方法, Security 认证登录接口调用的即该接口。

其中 AuthUserDao 是读取数据库数据的相应类,queryByName() 是通过 usernameauth_user 数据表进行精准查询,具体代码这里略去不表。

public interface AuthUserService extends UserDetailsService {

}

@Service("authUserService")
public class AuthUserServiceImpl implements AuthUserService {

    @Resource
    private AuthUserDao authUserDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AuthUser authUser = authUserDao.queryByName(username);
        if (authUser == null) {
            throw new IllegalArgumentException("User [" + username + "] doesn't exist.");
        }
        return authUser;
    }
}

4. Controller

控制层就比较简单了,开放了两个接口分别对应两类不同角色权限供后续测试。

@RestController
@RequestMapping("api/resource")
public class ResourceController {

    @GetMapping("user")
    public String demo1() {
        return "User demo.";
    }

    @GetMapping("admin")
    public String demo2() {
        return "Admin demo.";
    }
}

四、工具类

1. AES加密

在工程的前后端数据传输中密码使用明文相对隐患较高,因此这里引入了 AES 加密,具体实现这里不具体展开介绍,直接附上代码。

需要注意其中的 DEFAULT_IVDEFAULT_KEY 必须为长度 16 位的字符串。

public class AESUtil {

    private final static String ALGORITHM = "AES/CBC/NoPadding";
    private final static String DEFAULT_IV = "1234567890123456";
    private final static String DEFAULT_KEY = "1234567890123456";

    public static String encrypt(String data) throws Exception {
        return encrypt(data, DEFAULT_KEY, DEFAULT_IV);
    }

    public static String desEncrypt(String data) throws Exception {
        return desEncrypt(data, DEFAULT_KEY, DEFAULT_IV);
    }

    public static String encrypt(String data, String key, String iv) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        int blockSize = cipher.getBlockSize();
        byte[] dataBytes = data.getBytes();
        int length = dataBytes.length;
        if (length % blockSize != 0) {
            length = length + (blockSize - (length % blockSize));
        }
        byte[] plaintext = new byte[length];
        System.arraycopy(dataBytes, 0, plaintext, 0, dataBytes.length);
        SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes());
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
        byte[] encrypted = cipher.doFinal(plaintext);
        return new Base64().encodeToString(encrypted);
    }

    public static String desEncrypt(String data, String key, String iv) throws Exception {
        byte[] encrypted1 = new Base64().decode(data);
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
        IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes());
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
        byte[] bytes = cipher.doFinal(encrypted1);
        return new String(bytes);
    }
}

2. JWT生成

通过引入 JWT 我们即可以实现用户的登录状态管理,其可以为字符串生成一串带过期时间的 Token 值,但当达到过期时间时对 Token 进行解密将抛出 ExpiredJwtException 异常。

通过对 ExpiredJwtException 异常的捕获,即可实现用户登录状态的判断,下述中的 createJWT()parseJWT() 分别代表 Token 的生成与解析。

public class TokenUtil {

    /**
     * 密钥
     */
    public static final String JWT_KEY = "ibudai";
    /**
     * 过期时间
     */
    public static final Long JWT_TTL = TimeUnit.MINUTES.toMillis(5);

    /**
     * 生成 Token
     */
    public static String createJWT(String data, Long ttlMillis) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        JwtBuilder builder = getJwtBuilder(data, ttlMillis, uuid);
        return builder.compact();
    }

    /**
     * 解析 Token
     */
    public static Claims parseJWT(String token) {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 生成加密后的秘钥
     */
    private static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JWT_KEY);
        return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    }

    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm algorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if (ttlMillis == null) {
            ttlMillis = JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)
                // 计算内容
                .setSubject(subject)
                // 签发者
                .setIssuer("budai")
                // 签发时间
                .setIssuedAt(now)
                // 加密算法签名
                .signWith(algorithm, secretKey)
                .setExpiration(expDate);
    }
}

五、权限配置

1. 基础配置

完成上述的准备工作现在就可以开始正式的 Security 模块配置。

新建 SecurityConfig 类并继承 WebSecurityConfigurerAdapter ,然后重写 configure(AuthenticationManagerBuilder auth) 方法,其中 AuthUserService 即上述创建的读取数据库角色数据类。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private AuthUserService authUserService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 动态读取数据库信息
        auth.userDetailsService(authUserService)
                // 自定义 AES 方式加密
                .passwordEncoder(new AESEncoder());
    }
}

上述我配置的是从数据库动态读取角色信息,当然如果需要测试的话也可以直接代码中写死,如下中定义了两个角色 budaiadmin 以及其相应的密码和角色权限。

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // 手动配置
    auth.inMemoryAuthentication()
            .withUser("budai").password("123456").roles("USER")
            .and()
            .withUser("admin").password("123456").roles("ADMIN", "USER")
            .and()
            // 自定义账号信息解析方式
            .passwordEncoder(new AESEncoder());
}

2. 自定义加密

Security 中默认提供了强哈希加密方式 BCryptPasswordEncoder ,但也可根据需要自定义,只需实现 PasswordEncoder 类并重写相应的接口即可。

其中 charSequence 为登录传入的 username 参数,matches() 形参中的 s 为数据库读取的密码值。这里因为前端工程中同样实现了 AES 数据加密,因为在每一步开始前都先进行了解密操作。

public class AESEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence charSequence) {
        String str = charSequence.toString();
        try {
            String plain;
            if (!Objects.equals(str, "userNotFoundPassword")) {
                plain = AESUtil.desEncrypt(str);
            } else {
                plain = str;
            }
            return AESUtil.encrypt(plain);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public boolean matches(CharSequence charSequence, String s) {
        try {
            String plain = AESUtil.desEncrypt(charSequence.toString());
            String result = AESUtil.encrypt(plain);
            return Objects.equals(result, s);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

3. 权限分配

完成用户角色的创建之后就是为不同角色分配不同资源权限,同样在上述的 SecurityConfig 类中重写 configure(HttpSecurity http) 方法。这里 freeAPIuserAPIadminAPI 配置的是相应用户可以访问的接口,这里我是配置在 yml 文件中通过注解获取。

Security 未认证访问时页面将会重定向至 /login 地址,这里通过formLogin().loginProcessingUrl("/api/auth/verify") 指定登录方式接口地址置为 /api/auth/verify,后续通过 API 请求方式即可调用之前 AuthUserService 中重写的 loadUserByUsername() 方法认证用户角色。

其中认证成功、失败与无权限认证三类事件的处理逻辑我这里采用的是匿名函数方式,具体内容在下一大点。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 免认证资源
     */
    @Value("${auth.api.free}")
    private String freeAPI;

    /**
     * 普通用户资源
     */
    @Value("${auth.api.user}")
    private String userAPI;

    /**
     * 超级用户资源
     */
    @Value("${auth.api.admin}")
    private String adminAPI;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        String[] freeResource = freeAPI.trim().split(",");
        String[] userResource = userAPI.trim().split(",");
        String[] adminResource = adminAPI.trim().split(",");
        http.authorizeRequests()
                // 设置任意角色都可访问
                .antMatchers(freeResource).permitAll()
                // 为不同权限分配不同资源
                .antMatchers(userResource).hasRole("USER")
                .antMatchers(adminResource).hasRole("ADMIN")
                // 默认无定义资源都需认证
                .anyRequest().authenticated()
                // 自定义认证访问资源
                .and().formLogin().loginProcessingUrl("/api/auth/verify")
                // 认证成功逻辑
                .successHandler(this::successHandle)
                // 认证失败逻辑
                .failureHandler(this::failureHandle)
                // 未认证访问受限资源逻辑
                .and().exceptionHandling().authenticationEntryPoint(this::unAuthHandle)
                .and().httpBasic()
                // 允许跨域
                .and().cors()
                // 关闭跨站攻击
                .and().csrf().disable();
    }   
}

六、逻辑处理

提前说明一下,后续中所用到的 Json 序列化框架采用的都是 Jackson ,后续代码中出现的 objectMapper 不作详细说明。

1. 成功处理

当用户通过认证之后需要完成两个步骤,生成 JWT Token 值返回用于后续登录状态管理,其次将用户角色生成 Authentication 认证码用于后续权限过滤。

这里的 JWT TokenAuthentication 都是通过请求头回传给前端,当前端收到后进行存储,后续的前端发出的所有的请求都将在请求头中添加这个两个参数,后端通过过滤器与 Security 实现登录状态的与权限访问管理。

其中 JWT 是根据登录用户的用户名密码与角色序列化后 Json 值计算得出,而 Authentication 的格式是 username:password 通过 Base64 编码后的值,计算完成后写入 response 的请求头中返回给前端。

private void successHandle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    AuthUser user = (AuthUser) authentication.getPrincipal();
    String token, plainPwd;
    try {
        AuthUserDTO userDTO = new AuthUserDTO();
        plainPwd = AESUtil.desEncrypt(user.getPassword()).trim();
        userDTO.setUsername(user.getUsername());
        userDTO.setPassword(plainPwd);
        userDTO.setRole(user.getRole());
        String key = objectMapper.writeValueAsString(userDTO);
        token = TokenUtil.createJWT(key, TimeUnit.MINUTES.toMillis(60));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    response.addHeader("token", token);
    // 生成 Authentication
    String auth = user.getUsername() + ":" + user.getPassword();
    response.addHeader("auth", "Basic " + Base64.getEncoder().encodeToString(auth.getBytes()));
    response.setContentType("application/json;charset=UTF-8");
    ResultData<Object> result = new ResultData<>(200, "success.", true);
    response.getWriter().write(objectMapper.writeValueAsString(result));
}

2. 失败处理

当用户没有通过 Security 的认证时,我们也应该通过状态码等信息给前端相应的提示,这里 ResultData 是我自己新建的通过后端结果返回值。

private void failureHandle(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
    String msg;
    if (exception instanceof LockedException) {
        msg = "Account has been locked, please contact the administrator.";
    } else if (exception instanceof BadCredentialsException) {
        msg = "Account credential error, please recheck.";
    } else {
        msg = "Account doesn't exist, please recheck.";
    }
    response.setContentType("application/json;charset=UTF-8");
    response.setStatus(203);
    ResultData<Object> result = new ResultData<>(203, msg, null);
    response.getWriter().write(objectMapper.writeValueAsString(result));
}

3. 无权拦截

同理,当用户在没有经过 Security 认证的前提下访问资源,我们应应该予以拦截并返回相应提示。

private void unAuthHandle(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
    String msg = "Please login and try again.";
    response.setContentType("application/json;charset=UTF-8");
    response.setStatus(203);
    ResultData<Object> result = new ResultData<>(203, msg, null);
    response.getWriter().write(objectMapper.writeValueAsString(result));
}

七、Filter配置

在上面的步骤中我们已经实现了 Security 对角色的权限认证,下面通过滤器利用 JWT 实现对登录状态的动态管理。

1. Bean注入

过滤器的创建和使用在之前的文章已经介绍过了,这里不详细直接附上代码。

@Configuration
public class FilterConfig {
    
    /**
     * 设置放行资源
     * 
     * 例:/api/auth/verify
     */
    @Value("${auth.api.verify}")
    private String verifyAPI;

    @Bean
    public FilterRegistrationBean<AuthFilter> orderFilter1() {
        FilterRegistrationBean<AuthFilter> filter = new FilterRegistrationBean<>();
        filter.setName("auth-filter");
        // Set effect url
        filter.setUrlPatterns(Collections.singleton("/**"));
        // Set ignore url, when multiply the value spilt with ","
        filter.addInitParameter("excludedUris", verifyAPI);
        filter.setOrder(-1);
        filter.setFilter(new AuthFilter());
        return filter;
    }
}

2. 拦截逻辑

新建 AuthFilter 自定义过滤器类并实现 Filter 接口重写相应方法,这里着重介绍重写的 doFilter() 方法。

在上面我们已经说明了当通过登录认证成功后会将 JWT TokenAuthentication 写入请求头然后回传前端,后续前端所有请求的请求头中都应该包含这两个参数。

因此当登录成功之后的用户会会先触发 Security 验证,其正是通过 Authentication 值验证实现,这一部分由 Security 内部自行拦截处理我们无需操作。当通过认证之后则会触发我们配置的过滤器,在这里则需要对请求头中的 JWT Token 进行解析,若过期则需要返回相应提示,再由前端重定向至登录页面。

这里正是通过捕获 ExpiredJwtException 异常实现过期状态的判断,具体实现代码如下:

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse) servletResponse;
    int status;
    String msg;
    String token = req.getHeader("Token");
    if (StringUtils.isNotBlank(token)) {
        boolean isExpired = false;
        try {
            TokenUtil.parseJWT(token);
        } catch (ExpiredJwtException e) {
            isExpired = true;
        }
        if (!isExpired) {
            filterChain.doFilter(req, servletResponse);
            return;
        } else {
            status = 203;
            msg = "Login expired.";
        }
    } else {
        status = 203;
        msg = "Please login and try again.";
    }
    response.setContentType("application/json;charset=UTF-8");
    response.setStatus(status);
    ResultData<Object> result = new ResultData<>(status, msg, null);
    response.getWriter().write(objectMapper.writeValueAsString(result));
}

八、跨域处理

1. 工程配置

因为采用的是 Vue + Spring Boot 前后端分离的形式,因此可能存在跨域处理。

在工程中新建 CorsConfig 类实现 WebMvcConfigurer 接口并重写 addCorsMappings() 方法配置跨域信息。

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    /**
     * 设置跨域访问地址,逗号分隔
     * 
     * 例:http://localhost:8080,http://127.0.0.1:8080
     */
    @Value("${auth.host.cors}")
    private String hosts;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        String[] crosHost = hosts.trim().split(",");
        // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns(crosHost)
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(TimeUnit.SECONDS.toMillis(5));
    }
}

九、前端工程

完成上述操作之后就正式完成后端的所有操作,下面介绍前端应该配置哪些信息。

1. 信息存储

在之前已经提到过了当通过认证之后后端会将 JWT TokenAuthentication 信息写入响应的请求头中回传,这里前端我通过 localStorage 方式实现信息存储。

下述为登录按钮事件的核心实现,其中 authVerify() 调用的接口即之前配置的 /api/auth/verify 后端接口,需要传入两个参数 usernamepassword

authVerify(values).then((res) => {
    if (res.data.data) {
        const auth = res.headers['auth']
        const token = res.headers['token']
        localStorage.setItem("auth", auth)
        localStorage.setItem("token", token)
        this.$message.success("Login successful.");
        this.clear();
    } else {
        this.$message.error("Login failed, try again.");
    }
});

2. 请求认证

完成登录之后,在后续的所有请求中我们需要将 TokenAuthorization 参数写入请求的请求头中以供后端进行登录和权限认证。

在前端的 Axios 的配置文件中添加如下代码,在每次发送请求之前都会从 localStorage 中读取存储的认证信息并写入请求头,通过监控后端返回结果,对于未授权或登录过期用户重定向至登录页面。

function request(axiosConfig) {
    service.interceptors.request.use(config => {
        // 请求添加默认请求头
        const token = getToken()[0]
        if (token !== '') {
            config.headers['Token'] = token
        }
        const auth = getToken()[1]
        if (auth !== '') {
            config.headers['Authorization'] = auth
        }
        return config
    }, err => {
        const errorBody = err.response.data
        Vue.prototype.$notification['error']({
        message: errorBody.error,
        description: errorBody
        })
    })
    service.interceptors.response.use(res => {
        // 对于未授权重定向至登录页
        if (res.status === 203) {
            Vue.prototype.$notification['error']({
                message: 'Non-Authoritative',
                description: res.data.msg
            })
            router.push('/login')
        } else {
            return res
        }
    }, err => {
        const errorBody = err.response.data
        Vue.prototype.$notification['error']({
            message: 'Internal Server Error',
            description: errorBody.error
        })
    })
    return service(axiosConfig)
}

function getToken() {
    let token = localStorage.getItem('token')
    if (token === undefined || token === null) {
       token = ''
    }
    let auth = localStorage.getItem('auth')
    if (auth === undefined || auth === null) {
       auth = ''
    }
    return [token, auth]
}

3. 路由守卫

路由守卫部分的功能比较简单,当用户未认证登录之前若访问非登录页面内容则进行拦截,并重定向至登录页面。

router.beforeEach((to, from, next) => {
    const token = localStorage.getItem('token')
    if (to.path !== '/login' && token === null) {
        next('/login')
        Vue.prototype.$notification['error']({
        message: 'Non-Authoritative',
        description: 'Please login.'
        })
    } else {
        next()
    }
})

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录