折(mo)腾(yu)了好几天,终于把双重身份认证实现了。(账号密码jwt+短信验证码)
看了很多视频,照葫芦画瓢敲了两三次,遇到各种各样的bug,比如循环依赖(通过@PostConstructor+setter解决)、框架报错等,翻了上百次csdn才逐渐摸清。总算对spring-security有了一个大概的认识,写一点学习心得,希望能帮到初学者,同时以备自己未来复习。
spring-security的核心无非就在于一个AuthenticationManager。起辅助作用的有自上而下有Filter类、Provider类、Token类。项目结构中,Filter类在Controller类之上,我放在aop包下。Provider类实现核心登录功能,我放在core包下。Token类则是pojo,我放在domain包下,供参考。
我们目前只用上AuthenticationManager的默认实现ProviderManager类。它的核心在于其有一个List<AuthenticationProvider> providers,当外部调用它进行身份认证时,他会尝试调用这里的每个provider进行认证,并将结果返回。在配置类中可以修改这个provider列表,传入自定义的provider。
jwt认证实现的逻辑大致为:用户通过所有人(准确的说是匿名用户)都可以访问的登录api提交账号密码,Service层将其封装为token对象并调用AuthenticationManager进行身份认证,若成功则将用户信息写redis缓存,并返回一个token(这里特指token字符串)给前端。之后,用户访问其他需要身份认证的api时,只需请求头里携带该token,即可被Filter顺利放行。
要设置身份认证,首先要把所有没登录的用户拦截,而保留登录api给未登录用户。这就是配置类public class WebSecurityConfig extends WebSecurityConfigurerAdapter。打上@EnableGlobalMethodSecurity(prePostEnabled = true)注解后即可在各个api前,使用@PreAuthorize注解控制访问权限,而不必编写大量链式调用配置代码。这个注解常用参数有”permitAll()”(所有)、”isAnonymous()”(匿名,即未认证),”isAuthenticated()”(已认证)、"hasAuthority('xxx')"(特定身份登录)等。
此外,配置类中还需重写@Overrider protected void configure(@NotNull HttpSecurity http)方法进行配置。具体来说,前后端分离开发需关闭csrf与session功能:http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);并将过滤器添加至框架自己的过滤器链中:http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);一般还需要编写@Bean public PasswordEncoder passwordEncoder(),重写@Bean @Override public AuthenticationManager authenticationManagerBean()方法(返回父类方法)才能正常运行。
WebSecurityConfig.java
/**
* Spring-Security配置
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private SmsAuthenticationProvider smsAuthenticationProvider;
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
@Override
protected void configure(@NotNull HttpSecurity http) throws Exception {
// 关闭csrf与session功能
http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 将token校验过滤器与短信验证码过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(@NotNull AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider());
auth.authenticationProvider(smsAuthenticationProvider);
}
}
传统账号认证可不用自行编写provider,实现UserDetailsService供框架查询用户密码和权限信息即可。具体来说重写@Override public UserDetails loadUserByUsername(String s)方法,调用DAO层查找用户,并使用装饰器模式将普通UserPO包装为User实体实现UserDetails。需要做的一些工作:重写@Override public Collection<? extends GrantedAuthority> getAuthorities()方法将List<String>权限列表映射为框架提供的权限SimpleGrantedAuthority类:authorities = roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());并且设置@JSONField(serialize = false) private List<SimpleGrantedAuthority> authorities不进行JSON序列化,防止redis存取出现异常。此外,重写@Override public String getUsername()方法按需返回,比如手机号密码登录就返回原UserPO的phone。
AuthServiceImpl.java
@Override
public UserDetails loadUserByUsername(@Validated @NotEmpty String s) {
// 查询用户信息
UserPO userPO = userRepository.selectByPhone(s);
if (userPO == null) {
throw new DbException(ServiceCode.USER_NOT_FOUND);
}
List<String> roleList = userRepository.listRoleById(userPO.getUserId());
// 把数据封装成UserDetails返回
return new User(userPO, roleList);
}
UserPO.java
/**
* t_user表
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_user")
public class UserPO implements Serializable {
private static final long serialVersionUID = 3042112031982669233L;
@TableId(type = IdType.AUTO)
private Long userId;
@TableField
private Date createTime;
@TableField
private Date updateTime;
@TableField("user_name")
private String name;
@TableField("user_phone")
private String phone;
@TableField
private Long schoolId;
@TableField("user_password")
private String password;
@TableField("user_qq")
private String QQ;
@TableField("user_email")
private String email;
}
User.java
/**
* 身份认证业务用户实体类
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
@Data
public class User implements UserDetails {
private static final long serialVersionUID = -6634318190888926617L;
private UserPO user;
private List<String> roleList;
// 只需序列化roleList即可,否则存入redis出现异常
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities;
public User(UserPO user, List<String> roleList) {
this.user = user;
this.roleList = roleList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (authorities != null) {
return authorities;
}
// 将roleList中String类型权限信息封装成SimpleGrantedAuthority对象
authorities = roleList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
// 使用Spring-Security框架的“用户名”实际上是手机号
@Override
public String getUsername() {
return user.getPhone();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
用户登录后得到的token如何起作用?我们需要重写一个public class JwtAuthenticationTokenFilter extends OncePerRequestFilter并将其在配置类中加入过滤器链即可。重写protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @NotNull FilterChain filterChain)方法,从请求头获取token,无token则进行拦截(即return返回):调用filterChain通知其他拦截器工作,最后返回。有token则使用JWTUtil进行解析(我使用了hutool提供的工具类),读redis缓存用户数据(若无则报错返回)(特别需要注意:Filter层位于Controller层之上,不可抛出异常,不会被Controller层中全局Advice捕获,需封装原生方法写入response)。成功读取后,创建Token并存入spring-security框架SecurityContextHolder即可。
JwtAuthenticationFilter.java
/**
* JWT令牌过滤器
* @apiNote 注意:该层不可抛异常,需调用WriteJSON方法写回
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
@Component
@AllArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final RedisUtil redisUtil;
private final WriterUtil writerUtil;
@Override
protected void doFilterInternal(
@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
if (StrUtil.isEmpty(token)) {
// 无token,拦截
filterChain.doFilter(request, response);
return;
}
try {
// 解析token
String uid = (String)JWTUtil.parseToken(token).getPayload("uid");
// alibaba的JsonObject,只能用Object接收,否则报强转异常
Object json = redisUtil.get("login:" + uid);
// 读redis
if (json == null) {
// 用户未登录。不能抛异常,Filter层在Advice之上。
writerUtil.WriteJSON(response, new ResponseVO(ApiCode.AUTH_FAILURE));
return;
}
User user = JSON.parseObject(json.toString(), User.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (Exception e) {
// token不合法。不能抛异常,Filter层在Advice之上。
writerUtil.WriteJSON(response, new ResponseVO(ApiCode.AUTH_ILLEGAL_TOKEN));
return;
}
// 处理完,放行
filterChain.doFilter(request, response);
}
}
至于登出,从SecurityContextHolder获取Token对象得到userid等信息,清除redis中对jwt的缓存即可。
?AuthServiceImpl.java
@Override
public void logout() {
// 获取用户id
UsernamePasswordAuthenticationToken authentication =
(UsernamePasswordAuthenticationToken)SecurityContextHolder.getContext().getAuthentication();
User user = (User)authentication.getPrincipal();
// 如果无值会被Filter拦截,无需ifn判断
Long uid = user.getUser().getUserId();
redisUtil.delete("login:" + uid);
}
若需多种登录认证,首先需要提供传统账号密码登录的provider,在配置类中通过@Bean public AuthenticationProvider authenticationProvider()方法实现,具体来说,一般使用框架自带的DaoAuthenticationProvider,使用setter设置UserDetailsService(供框架查询用户密码和权限信息)、PasswordEncoder(一般为BCrypt)即可返回。还需重写@Override protected void configure(@NotNull AuthenticationManagerBuilder auth)方法,设置使用的一系列provider。
看我干嘛,完整配置类代码上面给了
下面以短信验证码举例。短信验证码发送使用腾讯云申请个人密钥,调用sdk发送即可,同时将验证码写入redis缓存。具体参考第三方文档,这里不再赘述。首先需要重写一个自定义Provider:public class SmsAuthenticationProvider implements AuthenticationProvider,具体重写其中的@Override public Authentication authenticate(Authentication authentication)方法,读token对象中信息与redis缓存信息是否吻合,否则抛出异常返回。成功后,创建新的认证对象,并交由service写入redis,返回token。
SmsAuthenticationProvider.java
/**
* 短信验证码登录认证逻辑
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
@Component
public class SmsAuthenticationProvider implements AuthenticationProvider {
@Autowired
private GrantedAuthoritiesMapper authoritiesMapper;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private AuthService authService;
@Bean
public GrantedAuthoritiesMapper grantedAuthoritiesMapper() {
return new NullAuthoritiesMapper();
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof SmsVerifyCodeAuthenticationToken)) {
throw new ApiException();
}
SmsVerifyCodeAuthenticationToken smsVerifyCodeAuthenticationToken =
(SmsVerifyCodeAuthenticationToken) authentication;
String phone = smsVerifyCodeAuthenticationToken.getName();
String code = smsVerifyCodeAuthenticationToken.getVerifyCode();
UserDetails user = userDetailsService.loadUserByUsername(phone);
if (user == null) {
throw new ServiceException(ServiceCode.USER_NOT_FOUND);
}
// 验证码校验,失败抛异常返回,成功创建新token(认证后)
authService.codeVerify(phone, code);
return createSuccessAuthentication(authentication, user);
}
// 当前Provider仅支持校验短信验证码token
@Override
public boolean supports(Class<?> authentication) {
return SmsVerifyCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
/**
* 认证成功,将非认证token转为认证token
* @param authentication 身份认证
* @param user 用户信息
* @return 认证token
*/
protected Authentication createSuccessAuthentication(
@NotNull Authentication authentication,
@NotNull UserDetails user) {
Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
SmsVerifyCodeAuthenticationToken authenticationToken =
new SmsVerifyCodeAuthenticationToken(user, null, authorities);
authenticationToken.setDetails(authentication.getDetails());
return authenticationToken;
}
}
还需重写自己的Token类:public class SmsVerifyCodeAuthenticationToken extends AbstractAuthenticationToken,基本就是对着框架的UsernamePasswordAuthenticationToken疯狂照抄(一模一样),把credentials字段改为存储验证码,外部调用getCredentials返回验证码即可同账号密码验证。
SmsVerifyCodeAuthenticationToken.java
完全就照着UsernamePasswordAuthenticationToken扒的,我都不好意思贴
/**
* 短信验证码token
* @author Icy
* @version 1.0.0
* @since 1.0.0
*/
public class SmsVerifyCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private String verifyCode;
/**
* 未登录认证
* @param principal 认证信息
*/
public SmsVerifyCodeAuthenticationToken(Object principal, String verifyCode) {
super(null);
this.principal = principal;
this.verifyCode = verifyCode;
setAuthenticated(false);
}
public SmsVerifyCodeAuthenticationToken(Object principal, String verifyCode, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.verifyCode = verifyCode;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return verifyCode;
}
@Override
public Object getPrincipal() {
return principal;
}
public String getVerifyCode() {
return verifyCode;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
verifyCode = null;
}
}
|