springsecurity4+springboot 实现remember-me 发现springsecurity 的BUG
前言:现在开发中,记住我这个功能是普遍的,用户不可能每次登录都要输入用户名密码。昨天准备用spring security的记住我功能,各种坑啊,吐血 。
先看下具体实现吧。
spring security 对remember-me 进行了封装 ,大概流程是 首先用户的表单必须有这个记住我的字段。
1.安全配置
以下是代码 (红色字体注释是关键)
package com.ycmedia.security; import javax.servlet.http.HttpServletRequest; import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import com.ycmedia.constants.MySQLConfig; @Configuration @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired @Qualifier("customUserDetailsService") UserDetailsService userDetailsService; @Autowired private AuthenticationProvider authenticationProvider; //注入数据源 @Autowired @Qualifier("mysqlDS") private DataSource dataSource; @Autowired private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource; @Override protected void configure(HttpSecurity http) throws Exception { //允许所有用户访问”/”和”/home” http.authorizeRequests(). antMatchers( "/css/**", "/js/**", "/images/**", "/lib/**", "/skin/**" , "/bootstrap/**" , "/build/**" , "/documentation/**" , "/pages/**" ) .permitAll() //其他地址的访问均需验证权限 .anyRequest().authenticated() .and() .formLogin() //指定登录页是”/login” .loginPage("/login") .permitAll() //登录成功后可使用loginSuccessHandler()存储用户信息,可选。 .successHandler(loginSuccessHandler())//code3 .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) //退出登录后的默认网址是”/home” .logoutSuccessUrl("/home") .permitAll() .invalidateHttpSession(true) .and() //登录后记住用户,下次自动登录 //数据库中必须存在名为persistent_logins的表 //建表语句见code15
// 这里是核心 .rememberMe() .tokenValiditySeconds(1209600) //指定记住登录信息所使用的数据源 .tokenRepository(tokenRepository());//code4 } // @Override // protected void configure(HttpSecurity http) throws Exception { // //允许访问静态资源 // http.authorizeRequests() // .antMatchers( "/css/**", "/js/**", "/images/**", // "/resources/**", "/lib/**", "/skin/**", "/template/**" // , "/bootstrap/**" // , "/build/**" // , "/documentation/**" // , "/pages/**" // , "/plugins/**" // , "/skin/**") // .permitAll() // //登录和注册页面不需要权限验证 // .antMatchers("/login", "/registration","/registrationUser").permitAll() // //其他地址的访问均需验证权限 // .anyRequest().authenticated(). // //访问失败页url // and().formLogin() // //.failureUrl("/login?error"). // //默认访问页 // .loginPage("/login") // .permitAll().successHandler(loginSuccessHandler()). // and().logout() // .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) // //退出登录后的默认网址是”/home” // .logoutSuccessUrl("/home"). // // 注销会删除cookie // deleteCookies("remember-me") // .invalidateHttpSession(true) // //注销失败跳转到登录页面 // .permitAll().and() // .rememberMe() // .tokenValiditySeconds(1209600) // //指定记住登录信息所使用的数据源 // .tokenRepository(tokenRepository());//code4 // // // // //设置session //// http.sessionManagement().maximumSessions(1); //// http.sessionManagement().invalidSessionUrl("/login"); // } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/resources/**"); web.ignoring().antMatchers("/webjars/**"); } // @Override // protected void configure(AuthenticationManagerBuilder auth) // throws Exception { // //采用自定义验证 // auth.authenticationProvider(authenticationProvider); // // //需要采用加密 //// auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); // } @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(4); }
//spring security 内部都写死了,这里要把 这个DAO 注入 @Bean public JdbcTokenRepositoryImpl tokenRepository(){ JdbcTokenRepositoryImpl j=new JdbcTokenRepositoryImpl(); j.setDataSource(dataSource); return j; } /** * 用户或者管理员登录日志 */ @Bean public LoginSuccessHandler loginSuccessHandler(){ return new LoginSuccessHandler(); } @Autowired public void configureGlobalSecurity(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(authenticationProvider); auth.userDetailsService(userDetailsService); } }
2 前端页面, 这个简单
<div class="checkbox">
<label><input type="checkbox" id="rememberme" name="remember-me"/> Remember Me</label>
</div>
3 、数据库表,因为 spring security 内部把表写死了, 可以看源码
* @author Luke Taylor * @since 2.0 */ public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository { // ~ Static fields/initializers // ===================================================================================== /** Default SQL for creating the database table to store the tokens */ public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, " + "token varchar(64) not null, last_used timestamp not null)"; /** The default SQL used by the <tt>getTokenBySeries</tt> query */ public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?"; /** The default SQL used by <tt>createNewToken</tt> */ public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)"; /** The default SQL used by <tt>updateToken</tt> */ public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?"; /** The default SQL used by <tt>removeUserTokens</tt> */ public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
这个表名必须 是persistent_logins
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
==========================================================================================其实到这里基本是没问题的,如果用 springsecurity 默认 授权验证
springsecurity 默认都是用户名+密码登录, 但是现在系统很多都是用户手机号+手机验证码登录,我这里就是这么实现的,手机验证码必须从第三方短信获取, 所以必须自定义授权验证
先看下我的自定义验证类
package com.ycmedia.security; import java.util.ArrayList; import java.util.Collection; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; import com.alibaba.fastjson.JSONObject; import com.ycmedia.constants.Constants; import com.ycmedia.entity.Customer; import com.ycmedia.entity.Role; import com.ycmedia.service.UserService; import com.ycmedia.utils.HttpRequest; /** * @author 自定义验证 * */ @Component public class YcAnthencationProder implements AuthenticationProvider { @Autowired private UserService userService; BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Autowired private Environment env; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication // .getDetails(); // 如上面的介绍,这里通过authentication.getDetails()获取详细信息 // 用户名 String username = authentication.getName(); // 验证码 String password = (String) authentication.getCredentials(); Customer user = userService.getUserByname(username); List<SimpleGrantedAuthority> auths = new ArrayList<>(); System.out.println("用户"+authentication.getName()+"正在获取权限"); //游客=》提示用户去注册 if(user==null){ //授权 auths.add(new SimpleGrantedAuthority(Role.ROLE_TOURIST.toString())); auths.add(new SimpleGrantedAuthority(username)); auths.add(new SimpleGrantedAuthority(password)); return new UsernamePasswordAuthenticationToken(new Customer(), password, auths); }else{ //存在此用户,调用登录接口 String data = HttpRequest.sendGet(env.getProperty("login.url"), "mobile=" + username+"&smsCode="+password); JSONObject json = JSONObject.parseObject(data); if(json.getBoolean("success")==true)){ //验证码和手机号码正确,返回用户权限 switch(user.getRole()){ case 0:auths.add(new SimpleGrantedAuthority(Role.ROLE_USER.toString())); case 1:auths.add(new SimpleGrantedAuthority(Role.ROLE_CHANNEL.toString())); case 2:auths.add(new SimpleGrantedAuthority(Role.ROLE_ADMIN.toString())); } }else{ //验证消息放到权限里面, 页面提示 auths.add(new SimpleGrantedAuthority(Role.ROLE_WRONGCODE.toString())); auths.add(new SimpleGrantedAuthority(username)); auths.add(new SimpleGrantedAuthority(password)); } } return new RememberMeAuthenticationToken(username, user, auths); // return new UsernamePasswordAuthenticationToken(user, password, // auths); } @Override public boolean supports(Class<?> authentication) { return authentication.equals(UsernamePasswordAuthenticationToken.class); } }
实现 这个接口就行AuthenticationProvider,
但是,结果
return new UsernamePasswordAuthenticationToken(user, password,
auths);
返回一个 UsernamePasswordAuthenticationToken 对象, 这个对象,是继承了 AbstractAuthenticationToken
而AbstractAuthenticationToken 又是 Authentication 的子类,
这个UsernamePasswordAuthenticationToken 看下他的源码
/* Copyright 2004, 2005, 2006 Acegi Technology Pty Limited * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.security.authentication; import java.util.Collection; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; /** * An {@link org.springframework.security.core.Authentication} implementation that is * designed for simple presentation of a username and password. * <p> * The <code>principal</code> and <code>credentials</code> should be set with an * <code>Object</code> that provides the respective property via its * <code>Object.toString()</code> method. The simplest such <code>Object</code> to use is * <code>String</code>. * * @author Ben Alex */ public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; // ~ Instance fields // ================================================================================================ private final Object principal; private Object credentials; // ~ Constructors // =================================================================================================== /** * This constructor can be safely used by any code that wishes to create a * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()} * will return <code>false</code>. * */ public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; this.credentials = credentials; setAuthenticated(false); } /** * This constructor should only be used by <code>AuthenticationManager</code> or * <code>AuthenticationProvider</code> implementations that are satisfied with * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>) * authentication token. * * @param principal * @param credentials * @param authorities */ public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); // must use super, as we override } // ~ Methods // ======================================================================================================== public Object getCredentials() { return this.credentials; } public Object getPrincipal() { return this.principal; } public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } super.setAuthenticated(false); } @Override public void eraseCredentials() { super.eraseCredentials(); credentials = null; } }
发现上springsecurity 的问题主要是
private final Object principal;
principal 是个 object 类型的, 可以存的信息有两种,第一这个用户对象, 第二, 用户名。 spring security 默认存的是 用户对象。
@RequestMapping(value = "/order-list")
public ModelAndView addSystemUser(Model model) {
Customer user= (Customer) AuthUtils.getAuthenticationObject().getPrincipal();
model.addAttribute("role", user.getRole());
model.addAttribute("username", user.getMobile());
return new ModelAndView("order-list");
}
这是我的一个 controller 方法, Customer user= (Customer) AuthUtils.getAuthenticationObject().getPrincipal(); 返回客户所有, 包括角色 ,权限,然后扔给前端,,这样看起来没错 啊。
最最坑的 的地方来了
这里是重点。
先看下这个类 PersistentTokenBasedRememberMeServices
看名字, 持久化 token 记住我 service;
再看他的类前部分
//继承了 AbstractRememberMeServices
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { // 还记得之前安全配置注入的DAo吗, 在这里 private PersistentTokenRepository tokenRepository = new InMemoryTokenRepositoryImpl(); private SecureRandom random; public static final int DEFAULT_SERIES_LENGTH = 16; public static final int DEFAULT_TOKEN_LENGTH = 16; private int seriesLength = DEFAULT_SERIES_LENGTH; private int tokenLength = DEFAULT_TOKEN_LENGTH; public PersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) { super(key, userDetailsService); random = new SecureRandom(); this.tokenRepository = tokenRepository; }
=====================================这里看起来正常
再看看核心方法
// 当一个用户登录成功, 并使用记住我会调用这个份方法
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//这里就是spring security 有BUG的地方, 因为 Authentication successfulAuthentication 这个对象默认存的是 用户对象, getName() 返回的就是一个这个对象的地址值 String username = successfulAuthentication.getName(); logger.debug("Creating new persistent login for user " + username); //这里是存储这个token到数据库 PersistentRememberMeToken persistentToken = new PersistentRememberMeToken( username, generateSeriesData(), generateTokenData(), new Date()); try { tokenRepository.createNewToken(persistentToken); addCookie(persistentToken, request, response); } catch (Exception e) { logger.error("Failed to save persistent token ", e); } }
这是数据库的数据, username 是一个对象
,当用户关闭浏览器的后,
再次打开浏览器进入网址 ,立即报错
2016-11-23 12:18:44.056 [http-nio-7070-exec-1] DEBUG c.y.dao.UserDao.findUserByMobile - ==> Parameters: com.ycmedia.entity.Customer@42feadb2(String)
2016-11-23 12:18:44.087 [http-nio-7070-exec-1] DEBUG c.y.dao.UserDao.findUserByMobile - <== Total: 0
2016-11-23 12:18:44.087 [http-nio-7070-exec-1] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
java.lang.NullPointerException: null
第二次登录的时候根据这个名字去查 ,因为不是用户名, 而是用户名地址值, 肯定报错!
好吧, 用 UsernamePasswordAuthenticationToken 这个 子类做 记住我, 感觉不靠谱,
看下他还有别的兄弟没
发现, 大概只有一个兄弟, 感觉有点靠谱, 点进去
这是他的构造
public RememberMeAuthenticationToken(String key, Object principal, Collection<? extends GrantedAuthority> authorities) { super(authorities); if ((key == null) || ("".equals(key)) || (principal == null) || "".equals(principal)) { throw new IllegalArgumentException( "Cannot pass null or empty values to constructor"); } this.keyHash = key.hashCode(); this.principal = principal; setAuthenticated(true); }
三个参数吧, key , Object principal 用户信息 authorities 用户权限。
但是, 即使这里我把 key 设置成 用户名,principal 保存用户的对象,
// return new RememberMeAuthenticationToken(username, user, auths);
但是,
这里依然还是调用 获取 principal 而不是, key
看下去头都大了
最后没办法, 你改变不了他, 只能适应他
return new UsernamePasswordAuthenticationToken(username, password,
auths);
把 用户名存进去。
当别的 接口要获取 要多做一步查询根据用户名去查权限
@RequestMapping(value = "/customer-list") public ModelAndView addSystemUser(Model model) { String userName= (String) AuthUtils.getAuthenticationObject().getPrincipal(); Customer user=userService.getUserByname(userName); model.addAttribute("role", user.getRole()); model.addAttribute("username", user.getMobile()); return new ModelAndView("customer-list"); }
而不是比较方便的
@RequestMapping(value = "/customer-list") public ModelAndView addSystemUser(Model model) { Customer user= (Customer) AuthUtils.getAuthenticationObject().getPrincipal(); model.addAttribute("role", user.getRole()); model.addAttribute("username", user.getMobile()); return new ModelAndView("customer-list"); }
综上 我感觉 spring security 的 Authentication 对象, Object getPrincipal(); 获取用户的信息, 要么就是一个对象 , 要么新加一个字段存用户名, 这样搞成object类型。