Authentication

①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳✕✓✔✖

1. 常见认证模式

2. 用户名和密码认证

验证用户身份的最常见方法之一是验证用户名和密码。因此,Spring Security 为使用用户名和密码进行身份验证提供了全面的支持。

收集要认证的用户名和密码

Spring Security 提供了以下内置机制来从 读取用户名和密码HttpServletRequest

系统存储要对比的认证信息

2.1 第一步:收集用户名与密码

发现用户没有登录,那么跳转到登录页面:

通过一个 Filter,得到用户名与密码,并封装成 Token。UsernamePasswordAuthenticationToken

2.2 第二部:进行认证

Spring 交给一个 AuthenticationManager 来进行认证。

AuthenticationManager 需要从本地读取要对比的数据,那么怎么读取呢? 就是上面说的 4 中存储方式,实际上最常用的是自定义的存储方式。实现这个也非常简单。

@Service
public class CustomUserDetailsService implements UserDetailsService {
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
return user;
}
}

到底怎对比,交给了 Spring 内部的对比模块来处理了,如果要自己做一个对比模块,也可以,就是稍微麻烦一点。

3. 案例:短信登录

项目中常见的一种登录形式是短信登录,那么利用上述的原理怎么实现短信登录呢?

  • 添加一个filter用来将前台传过来的手机号验证码保存到一个token中。
  • 添加一个校验代码,来检验token是否正确,如果正确,就把从customUserDetail中获得的用户信息,添加到SecurityContext
  • 经过上面两步就实现了短信认证。

例如下图,下面一个是用来输入的短信登录的。

具体的程序结构

3.1 短信登录

这部分代码尽量做到与业务无关,今后可以方便的进行迁移。

MobileAuthenticationToken

/**
* 用来进行校验的Token
*/
public class MobileAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID=4376675810462015013L;
private String mobile;
private String captcha;
public String getMobile() {
return mobile;
}
public String getCaptcha() {
return captcha;
}
public MobileAuthenticationToken(String mobile,String captcha){
super(null);
this.mobile=mobile;
this.captcha=captcha;
setAuthenticated(false);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return null;
}
}

MobileAuthenticationFilter

/**
* 这个Filter用来拦截进行手机验证码登录的程序
*/
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login/mobile",
"POST");
public MobileAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
/**
* 如果不想使用默认的"login/mobile地址来登录,可以通过这个函数来设置
* @param requiresAuthenticationRequestMatcher
*/
public MobileAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
super(requiresAuthenticationRequestMatcher);
}
/**
* 用来把request传递过来的电话号码与短信验证码给保存到token中
* @return 返回一个token
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)throws AuthenticationException {
String mobile=request.getParameter("mobile");
String captcha=request.getParameter("captcha");
MobileAuthenticationToken authRequest=new MobileAuthenticationToken(mobile,captcha);
return this.getAuthenticationManager().authenticate(authRequest);
}
}

MobileUserDetailsService:具体业务需要实现的逻辑

/**
* 需要有具体的实现类来实现
*/
public interface MobileUserDetailsService {
public UserDetails loadUserByMobile(String mobile) throws UsernameNotFoundException;
/**
* 需要根据业务逻辑,来实现短信校验
* @param mobile 手机号
* @param captcha 校验码
* @return true表示校验通过
*/
public Boolean checkCaptcha(String mobile,String captcha) ;
}

MobileAuthenticationProvider:具体校验规则

/**
* Mobile校验程序
* MessageSourceAware 是为了多语言,也可以不用。Spring可以自动调用setMessageSource
*
*/
public class MobileAuthenticationProvider implements AuthenticationProvider , MessageSourceAware {
protected final Log logger = LogFactory.getLog(getClass());
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private MobileUserDetailsService mobileUserDetailsService;
private UserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks();
private boolean forcePrincipalAsString = false;
/**
* 把自定义的service传入
* @param mobileUserDetailsService
*/
public MobileAuthenticationProvider(MobileUserDetailsService mobileUserDetailsService){
this.mobileUserDetailsService=mobileUserDetailsService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(MobileAuthenticationToken.class, authentication,
() -> this.messages.getMessage("MobileAuthenticationProvider.onlySupports",
"Only MobileAuthenticationProvider is supported"));
MobileAuthenticationToken token = (MobileAuthenticationToken)authentication;
UserDetails userDetails= mobileUserDetailsService.loadUserByMobile(token.getMobile());
if(userDetails==null){
throw new BadCredentialsException(this.messages
.getMessage("MobileAuthenticationProvider.badCredentials", "Bad credentials"));
}
//检查用户信息
preAuthenticationChecks.check(userDetails);
additionalAuthenticationChecks(token);
//返回
DefaultSuccessAuthentication successAuthentication=new DefaultSuccessAuthentication();
return successAuthentication.createSuccessAuthentication(authentication,userDetails);
}
/**
* 这里是附加校验,用来验短信是否正确
* @param token
* @throws AuthenticationException
*/
protected void additionalAuthenticationChecks(MobileAuthenticationToken token) throws AuthenticationException{
if(!mobileUserDetailsService.checkCaptcha(token.getMobile(),token.getCaptcha())){
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("MobileAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
/**
* 只有传入的token的类型=MobileAuthenticationToken,才进行判断
* @param authentication
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
return (MobileAuthenticationToken.class.isAssignableFrom(authentication));
}
/**
* 这个函数会自动被spring框架调用,用来做多语言处理
* @param messageSource
*/
@Override
public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}
}

3.2 业务逻辑

将短信的业务逻辑统一集中到CustomUserDetailsService

今后关于用户登录相关的业务代码都集中在这个类中了。

@Service
public class CustomUserDetailsService implements UserDetailsService, MobileUserDetailsService {
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
return user;
}
public UserDetails loadUserByMobile(String mobile) throws UsernameNotFoundException {
UserDetails user = User.builder()
.username("user123")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
return user;
}
public Boolean checkCaptcha(String mobile,String captcha){
if(captcha.equalsIgnoreCase("1234")){
return true;
}
return false;
}
}

3.3 配置

MobileLoginConfigurer 这个文件是整个业务的逻辑入口:

public final class MobileLoginConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private MobileUserDetailsService mobileUserDetailsService;
public MobileLoginConfigurer(MobileUserDetailsService mobileUserDetailsService){
this.mobileUserDetailsService=mobileUserDetailsService;
}
@Override
public void configure(HttpSecurity http) {
MobileAuthenticationFilter authFilter= new MobileAuthenticationFilter();
//添加authenticationManager
AuthenticationManager authenticationManager=http.getSharedObject(AuthenticationManager.class);
authFilter.setAuthenticationManager(authenticationManager);
//添加session处理逻辑
SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
//添加remember服务
RememberMeServices rememberMeServices = http.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
authFilter.setRememberMeServices(rememberMeServices);
}
//将MobileFilter添加到一个认证服务的前面
http.addFilterBefore(postProcess(authFilter)
, AbstractPreAuthenticatedProcessingFilter.class);
//添加权限判断逻辑
MobileAuthenticationProvider mobileAuthenticationProvider=new MobileAuthenticationProvider(mobileUserDetailsService);
http.authenticationProvider(mobileAuthenticationProvider);
}
}

3.4 引入

WebSecurityConfigurerAdapter将短信登录加了进来。有以下注意事项:

  • 如果配置了登录界面。就必须配置logout

  • 使用http.apply(mobileLoginConfigurer);来追加新添加的校验逻辑

@Configuration
@EnableWebSecurity
@Log4j2
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
CustomUserDetailsService customUserDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
//所有的必须都要认证
http.authorizeRequests(request->request
.anyRequest().authenticated()
);
//进行用默认form登录认证
//http.formLogin(withDefaults());
//使用自定义表单: 必须要用permitAll,同时设定logout
http.formLogin(form->form
.loginPage("/login").permitAll()
);
//由于使用了自定的login界面,这时候logout也要自定义,因为默认的是用POST,这里要添加上GET
OrRequestMatcher logoutMatcher= new OrRequestMatcher(
new AntPathRequestMatcher("/logout","GET")
,new AntPathRequestMatcher("/logout","POST"));
http.logout(out->out
.logoutRequestMatcher(logoutMatcher)
.permitAll()
);
//配置session
sessionConfig(http);
//追加手机短信登录
MobileLoginConfigurer mobileLoginConfigurer=new MobileLoginConfigurer(customUserDetailsService);
http.apply(mobileLoginConfigurer);
}
/**
* 用来配置session只能登录一次
* @param http
*/
private void sessionConfig(HttpSecurity http){
SessionManagementConfigurer<HttpSecurity> sessionManagementConfigurer;
try{
sessionManagementConfigurer=http.sessionManagement();
//以下这句就可以控制单个用户只能创建一个session,也就只能在服务器登录一次
sessionManagementConfigurer.maximumSessions(1).expiredUrl("/login");
}catch (Exception e){
throw new RuntimeException(e.toString());
}
}
}

4. Session 管理

在传统的 Web 应用中有明确的 Session 管理,但是也要考虑到现在常用的基于 JWT 的前后台分离程序。

基于 Session 的主要应用逻辑有:

  • 统计一下现在的登录人数。
    • 可以在线踢人。
  • 防止一个账户多次登录。
  • Session 保存到 Redis 中

4.1 SessionRegistry

从 SessionRegitstry 中可以得到所有的 Session

SessionRegistry 有两种实现:

  • SessionRegistryImpl 是基于内存的 session 管理
  • SpringSessionBackedSessionRegistry
    • RedisIndexedSessionRepository 可以实现 Redis

4.2 得到当前登录用户

SecurityContextUtils.getLoginUser()

可以得到当前登录的 SessionID。 但是与 request 中得到的 sessionID 不同。String sessionId=request.getSession().getId();

4.3 得到所有的登录用户

List<Object> list= sessionRegistry.getAllPrincipals();

4.4 并发策略

Spring Security Session 管理

http.
......
.maximumSessions(1)//最大session并发数量1
.maxSessionsPreventsLogin(false)//false之后登录踢掉之前登录,true则不允许之后登录
.expiredSessionStrategy(new MerryyounExpiredSessionStrategy())//登录被踢掉时的自定义操作
.....

MerryyounExpiredSessionStrategy

@Slf4j
public class MerryyounExpiredSessionStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent eventØ) throws IOException, ServletException {
eventØ.getResponse().setContentType("application/json;charset=UTF-8");
eventØ.getResponse().getWriter().write("并发登录!");
}
}

5.5 集群环境 Session 处理

添加 spring-session-data-redis 依赖

<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.3.1.RELEASE</version>
</dependency>

配置 Spring-session 存储策略

spring:
redis:
host: localhost
port: 6379
session:
store-type: redis

5. 记住我

令牌有两种存储方式:

  • InMemoryTokenRepositoryImpl 仅用于测试。
  • JdbcTokenRepositoryImpl 它将令牌存储在数据库中。

5.1 源码分析

以上流程的说明

  • 所有的filter都集成AbstractAuthenticationProcessingFilter
    • 在这个类的successfulAuthentication()会调用RemberMeservice的方法。
  • 里在认证成功之后,调用了RememberMeServicesloginSuccess方法

② 登录

接下来看看下次登录的时候,是怎么进行记住我认证的。这里我们就直接看 RememberMeAuthenticationFilter

有两个:

  • JSESSIONID:要预防 CSRF 攻击,在 Session 退出时,要删除。
  • remember-me:记住我

5.3 如何启动其他形式

  • InMemoryTokenRepositoryImpl 仅用于测试。
  • JdbcTokenRepositoryImpl 它将令牌存储在数据库中。

可以手工设置tokenRepository或者使用 Bean

.rememberMe(remeberMe->{
remeberMe.userDetailsService(users);
remeberMe.tokenRepository(....)
})

Springsecurity 的 remember-me 功能,持久化数据改用 Redis 实现,步骤超级详细!!!

5.4 代码启发

读 spring 的 RememberMeAuthenticationFilter,有一点小小启发,这个 Filter 实际上跟以前的 JWT 的 Filter 查不多,Spring 考虑的更全面一点。

  • 直接从rememberMeServices.autoLogin得到一个登录后的 Authentication
  • 下面做的就比较细致了
    • 让其他的校验者再检测一下:this.authenticationManager.authenticate(rememberMeAuth)
    • 建立了一个空的SecurityContext:SecurityContextHolder.createEmptyContext();
    • 发布事件eventPublisher
    • 如果指定了successHandler,那么按照successHandler定义进行转发
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
// Store to SecurityContextHolder
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.logger.debug(LogMessage
.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
+ "rejected Authentication returned by RememberMeServices: '%s'; "
+ "invalidating remember-me token", rememberMeAuth),
ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}

6. OpenID

这个可以放一放。因为安全性,不推荐了。 建议将 OpenID 升级到 OpenID Connect

下面的文档感觉写的不专业,等后续再做说明:

7. 匿名认证与预认证

7.1 匿名认证

感觉不常用,就是为了判断Authentication不等于空。

AuthenticationTrustResolver 这个类可以重载对匿名用户登陆的判断逻辑。

如果是匿名用户,系统默认跳转到登陆页面。在 MFA 例子中有明确的使用方法。

7.2 预认证

还有预认证场景感觉也不常用。

预认证是根据 Header 中的信息,来获取用户名和GrantedAuthority,这样有一个缺陷,就是有恶意用户会伪造这个。

预认证通过下面的几个类来实现:

  • AbstractPreAuthenticatedProcessingFilter :得到用户名与证书,然后提交给AuthenticationManager
    • RequestHeaderAuthenticationFilter
  • PreAuthenticatedAuthenticationProvider
    • 会较用AuthenticationUserDetailsService,通过token得到一个用户信息。
  • UserDetailsByNameServiceWrapper:这是一个Wrapper,用来得到UserDetails
  • Http403ForbiddenEntryPoint

8. Run-As 与 Logout

8.1 Run-As

网上有一个列子,可以看看,但是感觉应用的地方不多。

8.2 Logout

protected void configure(HttpSecurity http) throws Exception {
http
.logout(logout -> logout
.logoutUrl("/my/logout")
.logoutSuccessUrl("/my/index")
.logoutSuccessHandler(logoutSuccessHandler)
.invalidateHttpSession(true)
.addLogoutHandler(logoutHandler)
.deleteCookies(cookieNamesToClear)
)
...
}

① LogoutHandler

会调用下面的一些类,主要处理一些退出登陆后的清理工作。

② LogoutSuccessHandler

登陆成功后的处理事件

  • SimpleUrlLogoutSuccessHandler : 会跳转到指定页面,缺省的是 /login?logout
  • HttpStatusReturningLogoutSuccessHandler : 针对 REST API 应用场景。

9. 监听认证事件

9.1 添加事件

WebSecurityConfigurerAdapter 中配置一个publisher

/**
* 监听登录成功或者失败的事件
* @param applicationEventPublisher
* @return
*/
@Bean
public AuthenticationEventPublisher authenticationEventPublisher
(ApplicationEventPublisher applicationEventPublisher) {
return new DefaultAuthenticationEventPublisher(applicationEventPublisher);
}

上面只添加了一些默认的异常,官网上有一个可以添加所有异常的说明文档

然后新定义一个事件捕获类

@Component
@Log4j2
public class AuthenticationEvents {
/**
* 登录成功的事件
* @param success 包含了登录成功后的用户信息
*/
@EventListener
public void onSuccess(AuthenticationSuccessEvent success) {
log.debug(success.toString());
}
/**
* 登录失败的事件,包含了登录的token
* @param failures
*/
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent failures) {
log.debug(failures.toString());
}
}

9.2 配置异常列表

默认情况下,DefaultAuthenticationEventPublisher 将为以下事件发布 AbstractAuthenticationFailureEvent

ExceptionEvent
BadCredentialsExceptionAuthenticationFailureBadCredentialsEvent
UsernameNotFoundExceptionAuthenticationFailureBadCredentialsEvent
AccountExpiredExceptionAuthenticationFailureExpiredEvent
ProviderNotFoundExceptionAuthenticationFailureProviderNotFoundEvent
DisabledExceptionAuthenticationFailureDisabledEvent
LockedExceptionAuthenticationFailureLockedEvent
AuthenticationServiceExceptionAuthenticationFailureServiceExceptionEvent
CredentialsExpiredExceptionAuthenticationFailureCredentialsExpiredEvent
InvalidBearerTokenExceptionAuthenticationFailureBadCredentialsEvent

通过 setAdditionalExceptionMappings 方法可以添加自定义异常对应的发布信息。

@Bean
public AuthenticationEventPublisher authenticationEventPublisher
(ApplicationEventPublisher applicationEventPublisher) {
Map<Class<? extends AuthenticationException>,
Class<? extends AbstractAuthenticationFailureEvent>> mapping =
Collections.singletonMap(FooException.class, FooEvent.class);
AuthenticationEventPublisher authenticationEventPublisher =
new DefaultAuthenticationEventPublisher(applicationEventPublisher);
authenticationEventPublisher.setAdditionalExceptionMappings(mapping);
return authenticationEventPublisher;
}

您可以提供一个包罗万象的事件以在任何 AuthenticationException 的情况下触发:

@Bean
public AuthenticationEventPublisher authenticationEventPublisher
(ApplicationEventPublisher applicationEventPublisher) {
AuthenticationEventPublisher authenticationEventPublisher =
new DefaultAuthenticationEventPublisher(applicationEventPublisher);
authenticationEventPublisher.setDefaultAuthenticationFailureEvent
(GenericAuthenticationFailureEvent.class);
return authenticationEventPublisher;
}