①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳✕✓✔✖
验证用户身份的最常见方法之一是验证用户名和密码。因此,Spring Security 为使用用户名和密码进行身份验证提供了全面的支持。
收集要认证的用户名和密码
Spring Security 提供了以下内置机制来从 读取用户名和密码HttpServletRequest
:
系统存储要对比的认证信息
发现用户没有登录,那么跳转到登录页面:
通过一个 Filter,得到用户名与密码,并封装成 Token。UsernamePasswordAuthenticationToken
Spring 交给一个 AuthenticationManager 来进行认证。
AuthenticationManager
需要从本地读取要对比的数据,那么怎么读取呢? 就是上面说的 4 中存储方式,实际上最常用的是自定义的存储方式。实现这个也非常简单。
@Servicepublic 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 内部的对比模块来处理了,如果要自己做一个对比模块,也可以,就是稍微麻烦一点。
项目中常见的一种登录形式是短信登录,那么利用上述的原理怎么实现短信登录呢?
filter
用来将前台传过来的手机号
与验证码
保存到一个token
中。token
是否正确,如果正确,就把从customUserDetail
中获得的用户信息,添加到SecurityContext
中例如下图,下面一个是用来输入的短信登录的。
具体的程序结构
这部分代码尽量做到与业务无关,今后可以方便的进行迁移。
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);}@Overridepublic Object getCredentials() {return null;}@Overridepublic 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*/@Overridepublic 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;}@Overridepublic 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*/@Overridepublic boolean supports(Class<?> authentication) {return (MobileAuthenticationToken.class.isAssignableFrom(authentication));}/*** 这个函数会自动被spring框架调用,用来做多语言处理* @param messageSource*/@Overridepublic void setMessageSource(MessageSource messageSource) {this.messages = new MessageSourceAccessor(messageSource);}}
将短信的业务逻辑统一集中到CustomUserDetailsService
今后关于用户登录相关的业务代码都集中在这个类中了。
@Servicepublic 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;}}
MobileLoginConfigurer 这个文件是整个业务的逻辑入口:
public final class MobileLoginConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {private MobileUserDetailsService mobileUserDetailsService;public MobileLoginConfigurer(MobileUserDetailsService mobileUserDetailsService){this.mobileUserDetailsService=mobileUserDetailsService;}@Overridepublic void configure(HttpSecurity http) {MobileAuthenticationFilter authFilter= new MobileAuthenticationFilter();//添加authenticationManagerAuthenticationManager 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);}}
在WebSecurityConfigurerAdapter
将短信登录加了进来。有以下注意事项:
如果配置了登录界面。就必须配置logout
使用http.apply(mobileLoginConfigurer);
来追加新添加的校验逻辑
@Configuration@EnableWebSecurity@Log4j2public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@AutowiredCustomUserDetailsService customUserDetailsService;@Overrideprotected void configure(HttpSecurity http) throws Exception {//所有的必须都要认证http.authorizeRequests(request->request.anyRequest().authenticated());//进行用默认form登录认证//http.formLogin(withDefaults());//使用自定义表单: 必须要用permitAll,同时设定logouthttp.formLogin(form->form.loginPage("/login").permitAll());//由于使用了自定的login界面,这时候logout也要自定义,因为默认的是用POST,这里要添加上GETOrRequestMatcher logoutMatcher= new OrRequestMatcher(new AntPathRequestMatcher("/logout","GET"),new AntPathRequestMatcher("/logout","POST"));http.logout(out->out.logoutRequestMatcher(logoutMatcher).permitAll());//配置sessionsessionConfig(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());}}}
在传统的 Web 应用中有明确的 Session 管理,但是也要考虑到现在常用的基于 JWT 的前后台分离程序。
基于 Session 的主要应用逻辑有:
从 SessionRegitstry 中可以得到所有的 Session
SessionRegistry 有两种实现:
SecurityContextUtils.getLoginUser()
可以得到当前登录的 SessionID。 但是与 request 中得到的 sessionID 不同。String sessionId=request.getSession().getId();
List<Object> list= sessionRegistry.getAllPrincipals();
http........maximumSessions(1)//最大session并发数量1.maxSessionsPreventsLogin(false)//false之后登录踢掉之前登录,true则不允许之后登录.expiredSessionStrategy(new MerryyounExpiredSessionStrategy())//登录被踢掉时的自定义操作.....
MerryyounExpiredSessionStrategy
@Slf4jpublic class MerryyounExpiredSessionStrategy implements SessionInformationExpiredStrategy {@Overridepublic void onExpiredSessionDetected(SessionInformationExpiredEvent eventØ) throws IOException, ServletException {eventØ.getResponse().setContentType("application/json;charset=UTF-8");eventØ.getResponse().getWriter().write("并发登录!");}}
添加 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: localhostport: 6379session:store-type: redis
令牌有两种存储方式:
InMemoryTokenRepositoryImpl
仅用于测试。JdbcTokenRepositoryImpl
它将令牌存储在数据库中。filter
都集成AbstractAuthenticationProcessingFilter
successfulAuthentication()
会调用RemberMeservice
的方法。RememberMeServices
的loginSuccess
方法接下来看看下次登录的时候,是怎么进行记住我认证的。这里我们就直接看 RememberMeAuthenticationFilter
有两个:
InMemoryTokenRepositoryImpl
仅用于测试。JdbcTokenRepositoryImpl
它将令牌存储在数据库中。可以手工设置tokenRepository
或者使用 Bean
.rememberMe(remeberMe->{remeberMe.userDetailsService(users);remeberMe.tokenRepository(....)})
Springsecurity 的 remember-me 功能,持久化数据改用 Redis 实现,步骤超级详细!!!
读 spring 的 RememberMeAuthenticationFilter
,有一点小小启发,这个 Filter 实际上跟以前的 JWT 的 Filter 查不多,Spring 考虑的更全面一点。
rememberMeServices.autoLogin
得到一个登录后的 Authenticationthis.authenticationManager.authenticate(rememberMeAuth)
SecurityContext
:SecurityContextHolder.createEmptyContext();
eventPublisher
successHandler
,那么按照successHandler
定义进行转发@Overridepublic 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 AuthenticationManagertry {rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);// Store to SecurityContextHolderSecurityContext 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);}
这个可以放一放。因为安全性,不推荐了。 建议将 OpenID 升级到 OpenID Connect
下面的文档感觉写的不专业,等后续再做说明:
感觉不常用,就是为了判断Authentication
不等于空。
AuthenticationTrustResolver
这个类可以重载对匿名用户登陆的判断逻辑。
如果是匿名用户,系统默认跳转到登陆页面。在 MFA 例子中有明确的使用方法。
还有预认证场景
感觉也不常用。
预认证是根据 Header 中的信息,来获取用户名和GrantedAuthority
,这样有一个缺陷,就是有恶意用户会伪造这个。
预认证通过下面的几个类来实现:
AbstractPreAuthenticatedProcessingFilter
:得到用户名与证书,然后提交给AuthenticationManager
RequestHeaderAuthenticationFilter
PreAuthenticatedAuthenticationProvider
AuthenticationUserDetailsService
,通过token
得到一个用户信息。UserDetailsByNameServiceWrapper
:这是一个Wrapper
,用来得到UserDetails
Http403ForbiddenEntryPoint
网上有一个列子,可以看看,但是感觉应用的地方不多。
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))...}
会调用下面的一些类,主要处理一些退出登陆后的清理工作。
登陆成功后的处理事件
/login?logout
。WebSecurityConfigurerAdapter
中配置一个publisher
/*** 监听登录成功或者失败的事件* @param applicationEventPublisher* @return*/@Beanpublic AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {return new DefaultAuthenticationEventPublisher(applicationEventPublisher);}
上面只添加了一些默认的异常,官网上有一个可以添加所有异常的说明文档
然后新定义一个事件捕获类
@Component@Log4j2public class AuthenticationEvents {/*** 登录成功的事件* @param success 包含了登录成功后的用户信息*/@EventListenerpublic void onSuccess(AuthenticationSuccessEvent success) {log.debug(success.toString());}/*** 登录失败的事件,包含了登录的token* @param failures*/@EventListenerpublic void onFailure(AbstractAuthenticationFailureEvent failures) {log.debug(failures.toString());}}
默认情况下,DefaultAuthenticationEventPublisher
将为以下事件发布 AbstractAuthenticationFailureEvent
:
Exception | Event |
---|---|
BadCredentialsException | AuthenticationFailureBadCredentialsEvent |
UsernameNotFoundException | AuthenticationFailureBadCredentialsEvent |
AccountExpiredException | AuthenticationFailureExpiredEvent |
ProviderNotFoundException | AuthenticationFailureProviderNotFoundEvent |
DisabledException | AuthenticationFailureDisabledEvent |
LockedException | AuthenticationFailureLockedEvent |
AuthenticationServiceException | AuthenticationFailureServiceExceptionEvent |
CredentialsExpiredException | AuthenticationFailureCredentialsExpiredEvent |
InvalidBearerTokenException | AuthenticationFailureBadCredentialsEvent |
通过 setAdditionalExceptionMappings
方法可以添加自定义异常对应的发布信息。
@Beanpublic 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
的情况下触发:
@Beanpublic AuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {AuthenticationEventPublisher authenticationEventPublisher =new DefaultAuthenticationEventPublisher(applicationEventPublisher);authenticationEventPublisher.setDefaultAuthenticationFailureEvent(GenericAuthenticationFailureEvent.class);return authenticationEventPublisher;}