项目集成 Spring Security 自定义登录(二)

364 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

前言

上一篇文章中,我们学习了使用 spring security 做最基本的拦截。因为目前流行的趋势是前后端分离,我们的登录接口也要适应,这一篇教程我们来实现登录 Restful 化和集成 Jwt。
什么是 JWT ?
英文全称 JSON Web Token,用通俗的话讲,就是使用既定规则(秘钥/公钥)生成的一串加密报文,JWT内部可以从这个报文里面解析出登录信息以及过期时间等信息。

  • 优点:静态化 token,不用持久化存储;分布式的情况不用考虑 session 会话机制
  • 缺点:无法注销,只能配合 redis 等工具进行一个黑名单处理

安装依赖

打开 pom.xml 文件,加入依赖
properties 标签中加入:

<jjwt.version>0.11.5</jjwt.version>

<dependencies> 标签中加入如下依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jjwt.version}</version>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

编写 JWT 工具类

使用 jwt 的时候,需要获取一些信息,例如从 jwt 的 token 中获取用户名,根据用户名生成 token 等。我们都可以简单编写一个工具类,用来供应目前或者将来的一些需要权限框架的项目复用。

文件: JwtTokenUtils.java

package com.example.auth.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.function.Function;

@Slf4j
@Component
public class JwtTokenUtil {

    // 规定 JWT_SECRET 的长度要很长
    private static final String JWT_SECRET = "cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com-cuifuan@aliyun.com";
    // 过期时间-毫秒计时 默认 7 天
    private static final Long JWT_EXPIRATION = 7L * 24 * 60 * 1000;

    private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;


    /**
     * 根据用户信息生成token
     */
    public String generateToken(String username) {

        Claims claims = Jwts.claims().setSubject(username);

        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(getSignKey())
                .compact();
    }

    /**
     * 从token中获取用户名
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 判断token是否有效
     * 两方面:token是否过期
     * token用户名是否和userDetails中用户名一致
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = this.extractUsername(token);
        if (null == userDetails) {
            return false;
        }
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    /**
     * 判断token是否失效
     */
    public boolean isTokenExpired(String token) {
        Date expiredDate = this.getExpiredDateFromToken(token);
        return expiredDate.before(new Date());
    }

    /**
     * 从token中获取失效时间
     */
    public Date getExpiredDateFromToken(String token) {
        Claims claims = extractAllClaims(token);
        return claims.getExpiration();
    }

    /**
     * 从token中获取负载
     */
    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 生成token失效时间
     */
    private Date generateExpirationDate() {
        return new Date(System.currentTimeMillis() + JWT_EXPIRATION);
    }

    private static Key getSignKey() {
        // 使用我们的 JWT_SECRET 密钥签署我们的 JWT
        byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(JWT_SECRET);
        return new SecretKeySpec(apiKeySecretBytes, SIGNATURE_ALGORITHM.getJcaName());
    }
}

改造安全配置类

文件:SecurityConfiguration.java

  1. 增加 Bean -> AuthenticationManager,用来在等用户登录时,校验用户名与密码
  2. 剔除 Session 机制,改造为基于 token 机制
  3. 关闭 CSRF
  4. 根据文档变动,改动一些代码,保证项目正常运行
  5. 文档地址:官方变动文档,写的其实是有些乱的,国外网友已经在评论开始抡起了键盘...
package com.example.auth.config;

import com.example.auth.service.AdminService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

/**
 * desc: 保护接口组件
 * date 2022/7/25
 *
 * @author 程序员鱼丸
 **/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    private static final String[] IGNORE_API = new String[]{"/user", "/api/login"};
    private AdminService userDetailsService;

    @Autowired
    public void setUserDetailsService(AdminService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz ->
                        // 放行一些接口,在 IGNORE_API 中
                        authz.antMatchers(IGNORE_API).permitAll()
                                // 除了放行的接口其他全校验
                                .anyRequest().authenticated()
                )
                // 使用 Spring Security 提供的默认值启用安全功能
                .httpBasic(withDefaults())
                // 指定用户业务层,此业务层需要实现 Spring Security 官方的 UserDetailsService 接口
                .userDetailsService(userDetailsService)
                .authenticationProvider(authenticationProvider())
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // 由于使用的是JWT,我们这里不需要csrf
                .csrf().disable();
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 密码的加密方式
        return new BCryptPasswordEncoder();
    }

    private AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // 指定密码加密方式
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    /**
     * desc: 代替旧版本
     *
     * @Override
     * @Bean public AuthenticationManager authenticationManagerBean() throws Exception {
     * return super.authenticationManagerBean();
     * }
     * <p>
     * date 2022/7/26
     * @author 程序员鱼丸
     **/
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}

业务层登录逻辑与登录接口

在业务层增加登录的逻辑,代码如下,能注释的基本都加了,没加的网页搜索下具体的,都有的

@Autowired
private AuthenticationManager authenticationManager;
private JwtTokenUtil jwtTokenUtils;

@Autowired
public void setJwtTokenUtils(JwtTokenUtil jwtTokenUtils) {
    this.jwtTokenUtils = jwtTokenUtils;
}

public AdminUser adminLogin(AdminUser admin) {
    // 生成用户名密码身份验证令牌
    UsernamePasswordAuthenticationToken upaToken = new UsernamePasswordAuthenticationToken(admin.getUsername(), admin.getPassword());
    // 验证器验证令牌
    authenticationManager.authenticate(upaToken);
    // 根据用户名获取用户信息
    final UserDetails userDetails = this.loadUserByUsername(admin.getUsername());
    // 根据用户名生成 token
    final String token = jwtTokenUtils.generateToken(userDetails.getUsername());
    // 根据用户信息与用户权限在 spring 上下文存储用户的权限信息
    Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authentication);

    // 封装返回前端的类信息
    AdminUser userInfo = this.getCurrentUserInfo();
    userInfo.setToken(token);
    // 防止密码返回
    userInfo.setPassword(null);

    return userInfo;
}

文件: ApiController.java

@Autowired
private AdminService adminService;

@PostMapping("/api/login")
public AdminUser adminLogin(@RequestBody AdminUser admin) {
    return adminService.adminLogin(admin);
}

测试登录接口

使用的工具为 Postman ,请求接口地址为:

http://127.0.0.1:13921/api/login

这个/api/login我在安全配置类中已经进行了放行,因为是登录的接口,不需要鉴权

image.png

可以看到返回的 token 中已经生成了我们需要的值,后续请求其他接口所需要的就是这个 token 值

至此我们的自定义登录已经完成了。

代码地址:github.com/cuifuan/aut…

总结

  • 安全配置类中关闭 csrf 与 session
  • 创建 Auth验证令牌关键 Bean
  • 放行登录接口
  • 引入 jwt 依赖,编写工具类生成 jwt-token

tips: 看下 github 代码,在配置文件增加了允许循环依赖,与实体类中增加了 token 属性