SpringBoot 入门:09 - 基于JWT的简单认证与授权

1,086 阅读4分钟

认证和授权在绝大多数项目中多少都会涉及到,我们这个项目采用 JWT 配合 Spring Security 来做,本篇教程以实现为主,不对这两个技术做过多的深入。

在 pom.xml 依赖配置中加入:

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.0</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>jakarta.xml.bind</groupId>
  <artifactId>jakarta.xml.bind-api</artifactId>
</dependency>

JWT配置

首先我们需要在 application.yml 文件中加一些 jwt 的配置:

# application.yml
jwt:
  issue: wxbox
  token-header: Authorization
  token-prefix: 'Bearer '
  expiration: 604800

# application-dev.yml
jwt:
  secret: 1048c08c3a502d78feex2b59ce243342

# application-prod.yml
jwt:
  secret: 1048c08c3a502d78feex2b59ce243342

然后创建一个 JWT 工具类:

package com.foxescap.wxbox.common;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Date;
import java.util.function.Function;

/**
 * @author xfly
 */
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtUtil {

    private String tokenHeader;

    private String tokenPrefix;

    private String issuer;

    private String secret;

    private Long expiration;

    /**
     * 创建Token
     * @param userDetails 用户信息
     * @return token
     */
    public String createToken(UserDetails userDetails) {
        final Date issuedAt = new Date();

        var roles = new ArrayList<String>();
        for (var role : userDetails.getAuthorities()) {
            roles.add(role.getAuthority());
        }

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .signWith(SignatureAlgorithm.HS256, secret)
                .claim("rol", String.join(",", roles))
                .setIssuer(issuer)
                .setIssuedAt(issuedAt)
                .setSubject(userDetails.getUsername())
                .setExpiration(new Date(issuedAt.getTime() + expiration * 1000))
                .compact();
    }

    /**
     * 判断Token是否过期
     * @param token token
     * @return true-过期 false-未过期
     */
    public boolean isTokenExpired(String token) {
        final Date expiration = getExpirationFromToken(token);

        return expiration.before(new Date());
    }

    /**
     * 判断Token是否合法
     * @param token token
     * @param userDetails 用户信息
     * @return true-合法 false-非法
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);

        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    /**
     * 从Token中获取用户名
     * @param token token
     * @return 用户名
     */
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    /**
     * 从Token中获取过期时间
     * @param token token
     * @return 过期时间
     */
    public Date getExpirationFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    /**
     * 分解Token,获取需要的部分
     * @param token token
     * @param claimsResolver 需要的部分的获取方法
     * @param <T> T
     * @return 需要的部分
     */
    private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {

        Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();

        return claimsResolver.apply(claims);
    }
}

其中,我们通过 @ConfigurationProperties(prefix = "jwt") 注解将上面的配置信息自动填充到相应属性上。

实现 UserDetails 接口

一般情况我们都需要实现 UserDetails 接口来自定义一些逻辑:

package com.foxescap.wxbox.model;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * @author xfly
 */
@Data
public class Admin implements UserDetails {

    @TableId(type = IdType.AUTO)
    private Long id;

    private String username;

    private String password;

    private String role;

    private String regIp;

    private String loginIp;

    private LocalDateTime loginAt;

    private Integer status;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));

        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return status == 1;
    }
}

注意我们使用了 Lombok 的 @Data 注解,如果没用则还需要重写 getUsername() 和 getPassword() 方法。

实现 UserDetailService 接口

这个接口只有一个抽象方法:loadUserByUsername(),在我们的 AdminService 中实现一下即可:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    var admin = lambdaQuery().eq(Admin::getUsername, s).one();
    if (admin != null) {
        return admin;
    }
    throw new UsernameNotFoundException("User not found with username: " + s);
}

实现认证过滤器

package com.foxescap.wxbox.filter;

import com.foxescap.wxbox.common.ApiResponse;
import com.foxescap.wxbox.common.JwtUtil;
import com.foxescap.wxbox.service.AdminService;
import io.jsonwebtoken.JwtException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
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;

/**
 * @author xfly
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    private final AdminService adminService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil, AdminService adminService) {
        this.jwtUtil = jwtUtil;
        this.adminService = adminService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String authTokenHeader = request.getHeader(jwtUtil.getTokenHeader());
        String token;
        String username;

        if (authTokenHeader == null || !authTokenHeader.startsWith(jwtUtil.getTokenPrefix())) {
            SecurityContextHolder.clearContext();
        } else {
            token = authTokenHeader.replaceAll(jwtUtil.getTokenPrefix(), "");
            try {
                username = jwtUtil.getUsernameFromToken(token);
                UserDetails userDetails = adminService.loadUserByUsername(username);
                if (jwtUtil.validateToken(token, userDetails)) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                }
            } catch (JwtException e) {
                ApiResponse.fail(response, e.getMessage());
                return;
            }
        }

        chain.doFilter(request, response);
    }
}

其中的 ApiResponse.fail 方法如下:

/**
 * 失败返回
 * @param response HttpServletResponse
 * @param msg 信息
 * @throws IOException IOException
 */
public static void fail(HttpServletResponse response, String msg) throws IOException {
    response.setContentType("application/json; charset=utf-8");
    response.setCharacterEncoding("UTF-8");
    var out = response.getOutputStream();
    out.write(new ObjectMapper().writer().writeValueAsString(ApiResponse.fail(400, msg)).getBytes(StandardCharsets.UTF_8));
    out.flush();
    out.close();
}

此时 JWT 和 Spring Security 还是各自为战,需要通过 WebSecurityConfigurerAdapter 中 configure 方法的 addFilterBefore 将这个过滤器添加进去才行,我们配置一下 WebSecurityConfig.java 文件:

package com.foxescap.wxbox.config;

import com.foxescap.wxbox.filter.JwtAuthenticationFilter;
import com.foxescap.wxbox.service.AdminService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @author xfly
 */
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final AdminService adminService;

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public WebSecurityConfig(AdminService adminService, JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.adminService = adminService;
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.userDetailsService(adminService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .cors()
                .and()
                .authorizeRequests()
                .antMatchers("/admin/**").authenticated()
                .anyRequest().permitAll();

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

实现登录接口

package com.foxescap.wxbox.controller;

import com.foxescap.wxbox.common.ApiCode;
import com.foxescap.wxbox.common.ApiResponse;
import com.foxescap.wxbox.common.JwtUtil;
import com.foxescap.wxbox.dto.AdminInfoDto;
import com.foxescap.wxbox.dto.param.AdminLoginParam;
import com.foxescap.wxbox.service.AdminService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import java.util.stream.Collectors;

/**
 * @author xfly
 */
@RestController
@Validated
public class AdminController {

    private final AuthenticationManager authenticationManager;

    private final AdminService adminService;

    private final JwtUtil jwtUtil;

    public AdminController(AuthenticationManager authenticationManager, AdminService adminService, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.adminService = adminService;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/auth/admin")
    public ApiResponse<Object> login(@RequestBody @Valid AdminLoginParam param, HttpServletRequest request) {

        try {
            authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword()));
        } catch (BadCredentialsException e) {
            return ApiResponse.fail(ApiCode.API_USERNAME_PASSWORD_UNMATCHED);
        }

        UserDetails userDetails = adminService.loadUserByUsername(param.getUsername());

        adminService.login(userDetails.getUsername(), request.getRemoteAddr());

        String token = jwtUtil.createToken(userDetails);

        var data = new AdminInfoDto();
        data.setToken(token);
        data.setUsername(userDetails.getUsername());
        data.setRoles(userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
        return ApiResponse.success(data);
    }

    @GetMapping("/admin/info")
    public ApiResponse<AdminInfoDto> getInfo() {
        return ApiResponse.success(adminService.getInfo());
    }
}

比如上面 info 接口需要先认证,认证成功才会进入具体的业务逻辑,我们在业务逻辑中如果需要获取当前登录用户信息,就可以通过如下方式获取:

SecurityContextHolder.getContext().getAuthentication();

我们暂时只用到了 Spring Security 的一些基本功能,后续有待深入。

路漫漫其修远兮,吾将上下而求索。