设置自动登录的原因:
有的网站可能对密码要求比较繁琐(例如必须包含大小写甚至特殊字符),可能下次用户登录的时候会忘记密码,不得不找回密码从而又回到如何设计密码的环节
支持用户对信任的设备使用remember-me功能,直接登录提升用户登录体验
将用户的登录信息保存在用户浏览器的cookie中,当用户下次访问的时候,自动实现校验并建立登录态
Spring Security在自动登录中支持两种令牌(检验机制):
用散列算法加密用户必要的登录信息并生成令牌
散列算法主要是加密用户的用户名、密码、本次自动登录的有效期、指定的散列盐值key(用于防止令牌被篡改)
生成cookie(cookie中包含用户的登录信息,即上述的加密散列值)后,下次登录时SS首先会用Base64从cookie中解码得到用户名、自动登录有效期和加密散列值
然后SS根据username获取密码,重新使用相同的散列算法将username password 有效期 指定的散列盐值进行加密
将新加密后的散列值和从cookie中获取的加密散列值进行比对,如果相同说明令牌未被篡改,令牌有效
数据库等持久性数据存储机制持久化令牌
在交互上和散列算法一致,在用户勾选Remember-me之后,将生成的令牌发送到用户浏览器,并在用户下次访问系统时读取该令牌进行认证
核心是series和token,都是使用MD5散列后的随机字符串,series仅在用户使用密码重新登录时更新,token会在每一个新的session中重新生成
优势:
解决散列加密中一个令牌可多端登录的问题,因为每个会话都会创建一个新的token所以可以保证一个token只支持单例登录
自动登录不会导致series变更,每次自动登录都需验证series和token,当令牌还未使用过自动登录就被盗取时,系统会在非法用户验证通过后刷新token,而此时合法用户的浏览器中该token已失效,当合法用户自动的呢过路的时候由于series对应的token不同,系统可推断令牌被盗用,从而清理这个用户的所有自动登录令牌并通知该用户可能已被盗号
手动根据需求建表persistent_logins表,填入主键字段series,其他字段username token last_used
第一次登录成功后,SS框架捕获username,生成series和token插入到这张表中,并将这些信息写入cookie,base64加密后展示在浏览器
自动登录后,服务器从浏览器中获取cookie,base64解密后获取series和token,根据series从表中获取token匹配从前端获取的token,如果匹配说明用户信息正确,自动登录成功并更新token到表中这个series对应的token字段,另外更新last_uesd为系统当前时间(因为每次自动登录后都会更新token,所以不同会话token不同,不存在同一令牌多端登录的问题;另外如果token被非法用户登录上,token更新,原来的用户无法自动登录,那么清楚token并通知用户被盗)
当然不管是散列还是持久化数据存储机制,都存在cookie被盗取导致身份暂时被利用的可能,如果安全性要求较高推荐使用持久化(最好的办法就是不提供 自动登录的功能,但是往往在实际开发中,优质的体验优先于不可预期的安全风险)
如果提供自动登录的功能,应当限制cookie登录时的部分执行权限,比如修改密码,修改邮箱,查看隐私信息例如银行卡号等,如果需要查看那么就跳转登录页面或者进行手机/邮箱/使用独立密码进行身份验证(目前想到的实现方式:使用AOP切面织入到在这些方法中,如果是检测到是cookie登录也即使用自动登录的接口[可通过验证session检测,如果session中包含remember-me则属于cookie登录],并且想要调用这些方法,那么在执行方法之前即前置通知进行身份验证,前置通知通过后再执行切入点方法)
1. 在WebSecurityConfig框架配置类中配置加入remember-me配置,并绑定需要remember的用户认证方式,选择自定义实现UserDetailsService的UserDetailsServiceImpl对象。由于之前自定义的login.html并没有编写remember-me组件,所以此处删去自定义登录页面和重定向登录url的配置,直接使用SS框架自带的登录界面
@EnableWebSecurity // 自带@Configuration,此处无需再加@Configutaion注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsServiceImpl userDetailsServiceImpl;
/**
* 建立身份认证方式 AuthenticationManagerBuilder创建一个AuthenticationManager用于进行身份认证
* 包括:内存验证、LDAP验证、基于JDBC的验证、添加UserDetailsService、添加AuthenticationProvider
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用明文,不对密码进行加密处理
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(NoOpPasswordEncoder.getInstance());
}
/**
* 开启认证 配置需要认证的url
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 自定义登录配置
// authorizeRequests()方法返回一个URL拦截注册器,可调用其提供的anyRequest(),antMatchers()等方法匹配系统给的URL并为其制定安全策略
http.authorizeRequests()
*/
.antMatchers("/app/**").permitAll()
// 限制既有ADMIN又有USER的用户访问
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasRole('USER')")
.antMatchers("/user/**").hasRole("USER")
// 任意一个http请求都会进行认证
.anyRequest().authenticated()
.and()
// 设置表单验证登录
.formLogin()
// 设置登录页不设置访问限制,即登录页不用进行拦截
.permitAll()
// builder的编程链衔接方法 底层为this.getBuilder() 获取builder对象便于后续继续配置
// 给builder配置一个然后重新获取这个builder再继续下一个配置
.and()
// 禁止csrf攻击
.csrf().disable()
// 增加自动登录功能,默认为简单散列加密 底层调用TokenBasedRememberService类
.rememberMe().userDetailsService(userDetailsServiceImpl);
}
2. 重新运行项目,登录成功后F12可以看到cookie中除了传统的JSESSIOINID,多了remember-me属性
rememberMe()方法调用关于remember的相关配置,如果不自定义配置的话默认使用AbstractRememberService类中的配置,例如token令牌有效期为两个周,默认令牌字段名为remember-me,默认使用基于散列加密的自动登录方法,默认使用UUID生成的散列盐值key
// RememberMeConfigurer
private String getKey() {
if (this.key == null) {
if (this.rememberMeServices instanceof AbstractRememberMeServices) {
this.key = ((AbstractRememberMeServices)this.rememberMeServices).getKey();
} else {
this.key = UUID.randomUUID().toString();
}
}
return this.key;
}
那由于使用的是默认的配置,所以这个key在项目每次运行的时候都会重新UUID随机生成,这样的话其实同一个用户登录的时候只要项目重新运行了,他的cookie 生效;另外如果是多地部署,那么每个实例的key不同,如果用户登录另外一个部署的应用,key不同导致自动登录失败
解决的方法就是指定key,把key值定死,这样的话就算是重新运行项目,remember-me也不会改变
@EnableWebSecurity // 自带@Configuration,此处无需再加@Configutaion注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsServiceImpl userDetailsServiceImpl;
/**
* 建立身份认证方式 AuthenticationManagerBuilder创建一个AuthenticationManager用于进行身份认证
* 包括:内存验证、LDAP验证、基于JDBC的验证、添加UserDetailsService、添加AuthenticationProvider
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用明文,不对密码进行加密处理
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(NoOpPasswordEncoder.getInstance());
}
/**
* 开启认证 配置需要认证的url
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 自定义登录配置
// authorizeRequests()方法返回一个URL拦截注册器,可调用其提供的anyRequest(),antMatchers()等方法匹配系统给的URL并为其制定安全策略
http.authorizeRequests()
*/
.antMatchers("/app/**").permitAll()
// 限制既有ADMIN又有USER的用户访问
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasRole('USER')")
.antMatchers("/user/**").hasRole("USER")
// 任意一个http请求都会进行认证
.anyRequest().authenticated()
.and()
// 设置表单验证登录
.formLogin()
// 设置登录页不设置访问限制,即登录页不用进行拦截
.permitAll()
// builder的编程链衔接方法 底层为this.getBuilder() 获取builder对象便于后续继续配置
// 给builder配置一个然后重新获取这个builder再继续下一个配置
.and()
// 禁止csrf攻击
.csrf().disable()
// 增加自动登录功能,默认为简单散列加密,指定散列盐值key
.rememberMe().userDetailsService(userDetailsServiceImpl).key("security");
}
重新运行项目/多次刷新页面,JSESSIONID会发生变化,但是remember-me的值不变,说明该用户自动登录的状态一直保持着
当然也可以自定义配置这个自动登录的有效时间,在WebSecurityConfig中配置validTokenSeconds,例如配置为3s,那么重新运行项目可以发现多次刷新后remember-me值消失。消失后+登录还需手动登录
底层原理:选择remember-me之后,当登录成功后会调用RememberMeService相关方法,调用AbstractRememgberMeService方法中的loginSuccess方法,这个方法中会调用onLoginSuccess方法,默认使用实现类TokenBasedRememberMeService中的该方法,将username expiryTime 散列算法加密后的值存入cookie
// TokenBasedRememberMeService
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
// 从上下文中获取username SS框架中如果登录成功会生成一个Authentication对象,里面保存用户基本信息(用户名/密码/权限等信息)
String username = this.retrieveUserName(successfulAuthentication);
String password = this.retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
} else {
if (!StringUtils.hasLength(password)) {
UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
// 散列加密
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}
}
}
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey();
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException var8) {
throw new IllegalStateException("No MD5 algorithm available!");
}
return new String(Hex.encode(digest.digest(data.getBytes())));
}
等到下一次登录的时候,会解密cookie,然后会从cookie中取得username exprityTime 加密值,然后根据username获取密码,使用同样的加密算法将username expirtyTime password加密后和cookie中获取的加密值匹配 cookie的加密解密规则是base64
// TokenBasedRememberMeService
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException("Cookie token did not contain 3 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]);
} catch (NumberFormatException var8) {
throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1] + "')");
}
if (this.isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime) + "'; current time is '" + new Date() + "')");
} else {
// 根据用户名获取UserDetails对象
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> {
return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation";
});
// 使用相同加密方法对exprityTime username password 加密
String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
// 加密后与cookie中的旧加密值(创建cookie时根据exprityTime username password加密的值)
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
} else {
return userDetails;
}
}
}
}
当登录失败后,会清除掉cookie
1.创建persistent_logins表
2.在webSecurityConfig中添加持久化令牌,并绑定userDetailsService认证方法,并传入JdbcTokenRepository持久化操作
@EnableWebSecurity // 自带@Configuration,此处无需再加@Configutaion注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
DataSource dataSource;
@Autowired
UserDetailsServiceImpl userDetailsServiceImpl;
/**
* 建立身份认证方式 AuthenticationManagerBuilder创建一个AuthenticationManager用于进行身份认证
* 包括:内存验证、LDAP验证、基于JDBC的验证、添加UserDetailsService、添加AuthenticationProvider
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用明文,不对密码进行加密处理
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(NoOpPasswordEncoder.getInstance());
}
/**
* 开启认证 配置需要认证的url
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 由于此处的JdbcTokenRepostoryImpl是框架内置的,没办法加入到spring容器中,无法使用@Autowired方式注入依赖,只能使用new实例化对象导入依赖
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
// 自定义登录配置
// authorizeRequests()方法返回一个URL拦截注册器,可调用其提供的anyRequest(),antMatchers()等方法匹配系统给的URL并为其制定安全策略
http.authorizeRequests()
.antMatchers("/app/**").permitAll()
// 限制既有ADMIN又有USER的用户访问
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasRole('USER')")
.antMatchers("/user/**").hasRole("USER")
// 任意一个http请求都会进行认证
.anyRequest().authenticated()
.and()
// 设置表单验证登录
.formLogin()
// 设置登录页不设置访问限制,即登录页不用进行拦截
.permitAll()
// builder的编程链衔接方法 底层为this.getBuilder() 获取builder对象便于后续继续配置
// 给builder配置一个然后重新获取这个builder再继续下一个配置
.and()
// 禁止csrf攻击
.csrf().disable()
// 使用持久化令牌,使用tokenRepository定制令牌
.rememberMe().userDetailsService(userDetailsServiceImpl).tokenRepository(jdbcTokenRepository);
}
}
3.重启项目,登录后可以看到生成了remember-me,对应数据库中生成series和toke
4.更换一个浏览器登录相当于多端登录,会重新生成一条series-token记录
持久化令牌中令牌的存储方式有两种,一种是jdbc,一种是基于内存,根据需要new初始化对应的repositoryServiceImpl然后导入即可
用户第一次登录成功后,调用PersistentTokenBasedRememberMeServices方法中的onLoginSuccess(和前面基于散列类似,只不过底层最后调用不同的具体实现方法)
// PersistentTokenBasedRememberMeServices
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
// 根据username 创建的series和token 时间创建一个持久化令牌对象
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
// 将这个令牌存入persistent_logins表 此处最后使用的是JdbcTokenRepositoryImpl类中的createNewToken方法(webSeurityConfig配置了整个token存储的方式是基于JDBC的)
this.tokenRepository.createNewToken(persistentToken);
// 将令牌加入cookie中
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
// 指定series和token长度为16
private int seriesLength = 16;
private int tokenLength = 16;
protected String generateSeriesData() {
byte[] newSeries = new byte[this.seriesLength];
// 随机生成的byte字节数组
this.random.nextBytes(newSeries);
// 使用base64加密
return new String(Base64.getEncoder().encode(newSeries));
}
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}
当用户第二次登录的时候,会调用RememberMeServices接口的autoLogin方法,底层调用AbstractRememberMeServices的autoLogin方法,方法中调用PersistentTokenRememberMeServices的processAutoLoginCookie方法
// PersistentTokenRememberMeServices
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
// 获取cookie中的series
String presentedSeries = cookieTokens[0];
// 获取cookie中的token
String presentedToken = cookieTokens[1];
// 根据这个series从数据表persistent_logins中拿取对应的token对象即令牌
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) { // 从数据表获取的token值和从cookie中获取的token不一致
// 从数据库中移除令牌
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) { // 从数据表中获取的last_use加上令牌有效期小于当前时间即令牌过期
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
}
// 令牌token相同,验证通过,重新生成一个token,生成令牌更新到数据表这个series对应的数据中
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
try {
// 更新series对应的数据
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
// 将新的令牌加入到cookie
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
// 根据token中的usename前往用户授权 自动登录不验证密码,所以此处的loadByUsername应该不涉及用户密码(散列算法中涉及密码,内部本身就会从数据库中获取密码然后加密后匹配)
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
本身在配置WebSecurityConfig继承WebSecurityConfigurerAdapter的时候已经自带logout,可手动配置加入
@EnableWebSecurity // 自带@Configuration,此处无需再加@Configutaion注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsServiceImpl userDetailsServiceImpl;
/**
* 建立身份认证方式 AuthenticationManagerBuilder创建一个AuthenticationManager用于进行身份认证
* 包括:内存验证、LDAP验证、基于JDBC的验证、添加UserDetailsService、添加AuthenticationProvider
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用明文,不对密码进行加密处理
auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(NoOpPasswordEncoder.getInstance());
}
/**
* 开启认证 配置需要认证的url
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 自定义登录配置
// authorizeRequests()方法返回一个URL拦截注册器,可调用其提供的anyRequest(),antMatchers()等方法匹配系统给的URL并为其制定安全策略
http.authorizeRequests()
.antMatchers("/app/**").permitAll()
// 限制既有ADMIN又有USER的用户访问
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasRole('USER')")
.antMatchers("/user/**").hasRole("USER")
// 任意一个http请求都会进行认证
.anyRequest().authenticated()
.and()
// 设置表单验证登录
.formLogin()
// 设置登录页不设置访问限制,即登录页不用进行拦截
.permitAll()
.and()
// 配置登出
.logout()
.and()
// 禁止csrf攻击
.csrf().disable();
}
}