认证用户的过程:进入认证页面-->输入用户名和密码-->CSRF-->查询存储的用户数据(用户名、密码以及角色信息)-->认证完成
1 自定义认证页面
不使用Spring Security自带的认证页面,使用自己定义的。
- 释放静态资源,拦截器不要拦截静态资源。
- 匿名访问是要允许的,因为认证失败要可以跳转到认证页面。
- 配置登录页面,默认页面,登录失败页面。
<!--释放静态资源-->
<security:http pattern="/css/**" security="none"/>
<security:http pattern="/img/**" security="none"/>
<security:http pattern="/plugins/**" security="none"/>
<security:http pattern="/failer.jsp" security="none"/>
<security:http auto-config="true" use-expressions="true">
<!--拦截资源-->
<!--让认证页面可以匿名访问-->
<security:intercept-url pattern="/login.jsp" access="permitAll()"/>
<!--
pattern="/**" 表示拦截所有资源
access="hasAnyRole('ROLE_USER')" 表示只有ROLE_USER角色才能访问资源
-->
<security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER')"/>
<!--配置认证信息-->
<security:form-login login-page="/login.jsp"
login-processing-url="/login"
default-target-url="/index.jsp"
authentication-failure-url="/failer.jsp"/>
<!--配置退出登录信息-->
<security:logout logout-url="/logout"
logout-success-url="/login.jsp"/>
</security:http>
再次启动项目后就可以看到自定义的酷炫认证页面了!
然后你开开心心的输入了用户名user,密码user,就出现了如下的界面:
403什么异常?这是SpringSecurity中的权限不足!这个异常怎么来的?还记得上面SpringSecurity内置认证页面源码中的那个_csrf隐藏input吗?问题就在这了!
1.2 CSRF防护机制
1.2.1 CSRF攻击
定义:跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。简单来讲,如果一个站点欺骗用户提交请求到其他服务器的话,就会发生CSRF攻击,这可能会带来消极的后果。
场景:Tom登录银行网站没有退出,Jerry通过Sns获取了Tom的登录信息,向银行网站发送伪造的请求。
1.2.2 CSRF防护
CSRF防护有两种方法:
方式一:直接禁用csrf,不推荐。
方式二:在认证页面携带token请求。
(1)禁用csrf防护机制
在SpringSecurity主配置文件中添加禁用crsf防护的配置,禁用CSRF防护功能。通常来讲并不是一个好主意。如果这样做的话,那么应用就会面临CSRF攻击的风险。
<!--去掉csrf拦截的过滤器-->
<!--<security:csrf disabled="true"/>-->
(2)在认证页面携带token请求
Spring Security通过一个同步token的方式来实现CSRF防护的功能。它将会拦截状态变化的请求(例如,非GET、HEAD、OPTIONS和TRACE的请求)并检查CSRF token。如果请求中不包含CSRF token的话,或者token不能与服务器端的token相匹配,请求将会失败,并抛出CsrfException异常。
这意味着在你的应用中,所有的表单必须在一个“_csrf”域中提交token,而且这个token必须要与服务器端计算并存储的token一致,这样的话当表单提交的时候,才能进行匹配。
好消息是,Spring Security已经简化了将token放到请求的属性中这一任务,在JSP中创建一个“_csrf”隐藏域就可以实现CSRF防护:
这个就相当于
<input type="hidden">
name="${_csrf.parameterName}"
value="${_csrf.token}"/>
注:HttpSessionCsrfTokenRepository对象负责生成token并放入session域中。
1.3 注销登录
<form action="${pageContext.request.contextPath}/logout" method="post">
<security:csrfInput/>
<input type="submit" value="注销">
</form>
2 查询用户详细信息
查询用户的信息,判断用户当前的用户名和密码是否合法,并且查看当前用户拥有的角色(授权)。
好消息是,Spring Security非常灵活,能够基于各种数据存储来认证用户。它内置了多种常见的用户存储场景,如内存、关系型数据库以及LDAP。我们通常使用数据库进行用户数据的存储。
2.1 使用数据库中的数据实现认证操作
查看源码,实现用自己数据库中的数据来认证操作。
通过查看源码可以得知,我们可以直接编写一个UserDetailsService的实现类,告诉SpringSecurity我们要使用数据库中的数据认证用户。
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
//UserDetails就是SpringSecurity自己的用户对象。
//this.getUserDetailsService()其实就是得到UserDetailsService的一个实现类
//loadUserByUsername里面就是真正的认证逻辑
//也就是说我们可以直接编写一个UserDetailsService的实现类,告诉SpringSecurity就可以了!
//loadUserByUsername方法中只需要返回一个UserDetails对象即可
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}
2.2.1 UserService接口继承UserDetailsService
public interface UserService extends UserDetailsService {
public void save(SysUser user);
public List<SysUser> findAll();
public Map<String, Object> toAddRolePage(Integer id);
public void addRoleToUser(Integer userId, Integer[] ids);
}
2.2.2 编写loadUserByUsername业务
- 根据用户名查询用户的密码和角色。
- 将角色(授权)保存到authorities(一个用户可以拥有多个角色)。
- 创建UserDetails对象。
/**
* 认证业务
*
* @param username 用户在浏览器输入的用户名
* @return UserDetails 是springsecurity自己的用户对象
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
//根据用户名做查询
SysUser sysUser = userDao.findByName(username);
if (sysUser == null) {
return null;
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<SysRole> roles = sysUser.getRoles();
for (SysRole role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
//{noop}后面的密码,springsecurity会认为是原文。
UserDetails userDetails = new User(sysUser.getUsername(), "{noop}"+sysUser.getPassword(), authorities);
return userDetails;
} catch (Exception e) {
e.printStackTrace();
//认证失败!
return null;
}
}
2.2 使用数据库中的数据实现认证操作
看一下上面的认证查询,它会预期用户密码存储在了数据库之中。这里唯一的问题在于如果密码明文存储的话,会很容易受到黑客的窃取。但是,如果数据库中的密码进行了转码的话,那么认证就会失败,因为它与用户提交的明文密码并不匹配。
为了解决这个问题,我们需要借助passwordEncoder()方法指定一个密码转码器(encoder)。Spring Security的加密模块包括了三个这样的实现 :BCryptPasswordEncoder、NoOpPasswordEncoder和StandardPasswordEncoder。内置的是StandardPasswordEncoder。
不管你使用哪一个密码转码器,都需要理解的一点是,数据库中的密码是永远不会解码的。所采取的策略与之相反,用户在登录时输入的密码会按照相同的算法进行转码,然后再与数 据库中已经转码过的密码进行对比。这个对比是在PasswordEncoder的matches()方法中进行的。
2.2.1 在IOC容器中提供密码转换器
<!--把密码转换器对象放入的IOC容器中-->
<bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
<security:authentication-manager>
<security:authentication-provider user-service-ref="userServiceImpl">
<security:password-encoder ref="passwordEncoder"/>
</security:authentication-provider>
</security:authentication-manager>
2.2.2 修改认证方法
去掉{noop}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
//根据用户名做查询
SysUser sysUser = userDao.findByName(username);
if (sysUser == null) {
return null;
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
List<SysRole> roles = sysUser.getRoles();
for (SysRole role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
//{noop}后面的密码,springsecurity会认为是原文。
UserDetails userDetails = new User(sysUser.getUsername(),sysUser.getPassword(), authorities);
return userDetails;
} catch (Exception e) {
e.printStackTrace();
//认证失败!
return null;
}
}
2.2.3 手动将数据库中用户密码改为加密后的密文
public class Encode {
public static void main(String[] args) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode("123");
System.out.println(encode);
}
}
3 remember me 功能
对于应用程序来讲,能够对用户进行认证是非常重要的。但是站在用户的角度来讲,如果应 用程序不用每次都提示他们登录是更好的。这就是为什么许多站点提供了Remember-me功 能,你只要登录过一次,应用就会记住你,当再次回到应用的时候你就不需要登录了。
默认情况下,remember me功能是通过在cookie中存储一个token完成的,这个token最多两周内有效。存储在cookie中的token包含用户名、密码、过期时间和一个私钥——在写入cookie前都进行了MD5哈希。默认情况下,私钥的名为SpringSecured。
为了实现这一点,登录请求必须包含一个名为remember-me的参 数。在登录表单中,增加一个简单复选框就可以完成这件事情:
3.1 记住我功能原理分析
现在继续跟踪找到AbstractRememberMeServices对象的loginSuccess方法:
- 判断是否勾选记住我
- 若勾选就调用onLoginSuccess方法
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
如果上面方法返回true,就表示页面勾选了记住我选项了。
继续顺着调用的方法找到PersistentTokenBasedRememberMeServices的onLoginSuccess方法:
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
this.tokenRepository.createNewToken(persistentToken);
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
autoLogin():判断cookie是否存在,如果存在则自动登录
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = this.extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
} else {
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
this.cancelCookie(request, response);
return null;
} else {
UserDetails user = null;
try {
String[] cookieTokens = this.decodeCookie(rememberMeCookie);
user = this.processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return this.createSuccessfulAuthentication(request, user);
} catch (CookieTheftException var6) {
this.cancelCookie(request, response);
throw var6;
} catch (UsernameNotFoundException var7) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", var7);
} catch (InvalidCookieException var8) {
this.logger.debug("Invalid remember-me cookie: " + var8.getMessage());
} catch (AccountStatusException var9) {
this.logger.debug("Invalid UserDetails: " + var9.getMessage());
} catch (RememberMeAuthenticationException var10) {
this.logger.debug(var10.getMessage());
}
this.cancelCookie(request, response);
return null;
}
}
}
3.2 记住我功能页面代码
注意name和value属性的值不要写错哦!
<div class="checkbox icheck">
<label><input type="checkbox" name="remember-me" value="true"> 记住 下次自动登录</label>
</div>
3.3 开启remember me过滤器
<security:http auto-config="true" use-expressions="true">
<security:remember-me token-validity-seconds="60"/>
</security:http>
说明:RememberMeAuthenticationFilter中功能非常简单,会在打开浏览器时,自动判断是否认证,如果没有则调用autoLogin进行自动认证。
3.4 remember me安全性分析
记住我功能方便是大家看得见的,但是安全性却令人担忧。因为Cookie毕竟是保存在客户端的,很容易盗取,而且cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全(如下图所示)。那么这就要提醒喜欢使用此功能的,用完网站要及时手动退出登录,清空认证信息。
此外,SpringSecurity还提供了remember me的另一种相对更安全的实现机制 :在客户端的cookie中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),然后在db中保存该加密串-用户信息的对应关系,自动登录时,用cookie中的加密串,到db中验证,如果通过,自动登录才算通过。
创建一张表,注意这张表的名称和字段都是固定的,不要修改。
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
然后将spring-security.xml中 改为:
<!--
开启remember me过滤器,
data-source-ref="dataSource" 指定数据库连接池
token-validity-seconds="60" 设置token存储时间为60秒 可省略
remember-me-parameter="remember-me" 指定记住的参数名 可省略
-->
<security:remember-me
data-source-ref="dataSource"
token-validity-seconds="60"
remember-me-parameter="remember-me"/>
最后测试发现数据库中自动多了一条记录:
4 显示当前认证用户名
在header.jsp中找到页面头部最右侧图片处添加如下信息:
<span class="hidden-xs">
<%--<security:authentication property="principal.username" />--%>
<security:authentication property="name" />
</span>