认证和授权在绝大多数项目中多少都会涉及到,我们这个项目采用 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 的一些基本功能,后续有待深入。
路漫漫其修远兮,吾将上下而求索。