认证和授权在绝大多数项目中多少都会涉及到,我们这个项目采用 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> 复制代码
首先我们需要在 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 接口来自定义一些逻辑:
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() 方法。
这个接口只有一个抽象方法: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 的一些基本功能,后续有待深入。
路漫漫其修远兮,吾将上下而求索。