前言:現在開發中,記住我這個功能是普遍的,用戶不可能每次登錄都要輸入用戶名密碼。昨天准備用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類型。