前后端分离
主要实现避免后端控制页面跳转,后端通过返回json让前端控制页面跳转。
自定义登录成功Handler
之前代码中通过defaultSuccessUrl("/index")配置当登录成功后默认跳转到/index,不满足彻底的前后端分离。
我们可以用successHandler(myLoginSuccessHandler)添加登录成功处理器,当用户登录成功后处理并返回给前端json对象,因此无需配置defaultSuccessUrl("/index")。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
public PasswordEncoder p;
@Autowired
public UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
public MyLoginSuccessHandler myLoginSuccessHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl)
.passwordEncoder(p);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
//对于登录接口或登录页面不拦截
.antMatchers("/login","/mlogin.html").permitAll()
//对于接口访问必须需要某角色
.antMatchers("/role").hasRole("ROLE")
//对于接口访问必须需要某权限
.antMatchers("/perm").hasAuthority("p1")
//所有的请求必须经过认证(包括登录),除非加入上面的不拦截
.anyRequest().authenticated()
//再返回一个HttpSecurity http
.and()
//设置登录页面
.formLogin().loginPage("/mlogin.html")
//登录页面填写完成后的提交地址,默认是/login(SpringSecurity已经默认实现了此接口)
.loginProcessingUrl("/login")
//登录页面form表单中用户名框对应的name
.usernameParameter("username")
//登录页面form表单中密码框对应的name
.passwordParameter("password")
//登录成功处理器
.successHandler(myLoginSuccessHandler).permitAll()
//登录失败后访问
.failureUrl("/loginerror");
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
}
实现AuthenticationSuccessHandler的MyLoginSuccessHandler。ObjectMapper为springboot提供的一个将对象转换为json字符串的类,实际开发过程中可以采用其他json处理插件。
@Component
public class MyLoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
map.put("code", "400");
map.put("message", "登录成功!" + authentication.getName() + " : " + authentication.getAuthorities());
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
此时,当登录成功后调用MyLoginSuccessHandler并在前端返回json字符串结果,可通过authentication获取登录用户具体信息。
{ "message": "登录成功!admin:[ROLE_ROLE]"}
自定义登录失败Handler
类似successHandler同样有失败处理器failureHandler,在用户失败处理器中可以处理抛出的异常,功能同failureUrl("/loginerror"),因此无需配置failureUrl("/loginerror")。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
public PasswordEncoder p;
@Autowired
public UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
public MyLoginSuccessHandler myLoginSuccessHandler;
@Autowired
public MyLoginFailureHandler myLoginFailureHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl)
.passwordEncoder(p);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
//对于登录接口或登录页面不拦截
.antMatchers("/login","/mlogin.html","/mmlo","/mlogin").permitAll()
//对于接口访问必须需要某角色
.antMatchers("/role").hasRole("ROLE")
//对于接口访问必须需要某权限
.antMatchers("/perm").hasAuthority("p1")
//所有的请求必须经过认证(包括登录),除非加入上面的不拦截
.anyRequest().authenticated()
//再返回一个HttpSecurity http
.and()
//设置登录页面
.formLogin().loginPage("/mlogin.html")
//登录页面填写完成后的提交地址,默认是/login(SpringSecurity已经默认实现了此接口)
.loginProcessingUrl("/login")
//登录页面form表单中用户名框对应的name
.usernameParameter("username")
//登录页面form表单中密码框对应的name
.passwordParameter("password")
//登录成功处理器
.successHandler(myLoginSuccessHandler).permitAll()
//登录失败处理器
.failureHandler(myLoginFailureHandler);
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
}
实现AuthenticationFailureHandler的MyLoginFailureHandler。在里面可以通过AuthenticationException对异常进行处理。
@Component
public class MyLoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
String a = "";
if(e instanceof BadCredentialsException) {
a = "密码错误";
} else if(e instanceof DisabledException) {
a = "账户被禁用";
} else if(e instanceof AccountExpiredException) {
a = "账户已过期";
} else if(e instanceof LockedException) {
a = "账户被锁定";
} else if(e instanceof CredentialsExpiredException) {
a = "账户凭证过期";
} else if(e instanceof UsernameNotFoundException) {
//在springsecurity中UsernameNotFoundException被屏蔽无法使用,用户找不到会抛BadCredentialsException
a = "账户不存在";
} else {
a = "未知错误";
}
System.out.println(e.getMessage());
map.put("code", "401");
map.put("message", "登录失败! " + a);
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
此时,当登录失败后调用MyLoginFailureHandler并在前端返回json字符串结果,如密码错误。
{"message": "登录失败! 密码错误"}
用户未登录处理器
之前的代码中,loginPage("/mlogin.html")来指定未登录用户访问资源时,自动跳转到登录页面,如果不配置loginPage那么会自动跳转到SpringSecurity默认的登陆页面。
在前后端分离时,不使用loginPage设定页面跳转,可以用AuthenticationEntryPoint自定义处理器,当用户未登录访问受限资源时进行处理,无需配置oginPage("/mlogin.html")。
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
//对于登录接口或登录页面不拦截
.antMatchers("/login").permitAll()
//对于接口访问必须需要某角色
.antMatchers("/role").hasRole("ROLE")
//对于接口访问必须需要某权限
.antMatchers("/perm").hasAuthority("p1")
//所有的请求必须经过认证(包括登录),除非加入上面的不拦截
.anyRequest().authenticated()
//再返回一个HttpSecurity http
.and()
//设置登录页面
.formLogin()
//.loginPage("/mlogin.html")
//登录页面填写完成后的提交地址,默认是/login(SpringSecurity已经默认实现了此接口)
.loginProcessingUrl("/login")
//登录页面form表单中用户名框对应的name
.usernameParameter("username")
//登录页面form表单中密码框对应的name
.passwordParameter("password")
.successHandler(myLoginSuccessHandler).permitAll()
.failureHandler(myLoginFailureHandler)
//用户未登录处理器
.and().exceptionHandling().authenticationEntryPoint(myAuthExceptionEntryPoint);
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
自定义MyAuthExceptionEntryPoint实现AuthenticationEntryPoint,当用户未登录访问受限资源时提示未登录。
//匿名用户访问无权限的异常
@Component
public class MyAuthExceptionEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
map.put("code", "420");
map.put("message", "用户未登录");
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
已登录用户但权限不足处理器
之前的代码中,已登录用户访问受限的资源,此时会抛出默认的一个无权限页面(类似403 Forbidden)。
通过配置accessDeniedHandler(myAccessDeniedHandler)权限不足处理器,当登录用户权限不足时提示权限不足。
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
//对于登录接口或登录页面不拦截
.antMatchers("/login","/mlogin.html","/mmlo","/mlogin").permitAll()
//对于接口访问必须需要某角色
.antMatchers("/role").hasRole("ROLE")
//对于接口访问必须需要某权限
.antMatchers("/perm").hasAuthority("p1")
//所有的请求必须经过认证(包括登录),除非加入上面的不拦截
.anyRequest().authenticated()
//再返回一个HttpSecurity http
.and()
//设置登录页面
.formLogin()
.loginPage("/mlogin.html")
//登录页面填写完成后的提交地址,默认是/login(SpringSecurity已经默认实现了此接口)
.loginProcessingUrl("/login")
//登录页面form表单中用户名框对应的name
.usernameParameter("username")
//登录页面form表单中密码框对应的name
.passwordParameter("password")
.successHandler(myLoginSuccessHandler).permitAll()
.failureHandler(myLoginFailureHandler)
//匿名用户访问资源时无权限处理器
.and().exceptionHandling().authenticationEntryPoint(myAuthExceptionEntryPoint)
//已登录用户权限不足处理器
.accessDeniedHandler(myAccessDeniedHandler);
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
自定义MyAccessDeniedHandler实现AccessDeniedHandler。
//已登录用户但权限处理器
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
map.put("code", "430");
map.put("message", "用户无权限");
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
测试后,未登录用户访问受限资源后提示420错误,已登录用户访问无权资源提示430错误。
用户退出处理器
SpringSecurity中默认的用户退出接口为/logout,在前后端分离时我们自定义用户退出处理器并且可与实际业务进行关联。
用户退出的过程一般是,当前session失效,删除当前用户的RememberMe功能信息,清除当前的SecurityContext,删除cookie,最后重定向到登录页面(如loginPage配置)。
通过配置logout()设置该处理器。
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
//对于登录接口或登录页面不拦截
.antMatchers("/login","/mlogin.html","/mmlo","/mlogin").permitAll()
//对于接口访问必须需要某角色
.antMatchers("/role").hasRole("ROLE")
//对于接口访问必须需要某权限
.antMatchers("/perm").hasAuthority("p1")
//所有的请求必须经过认证(包括登录),除非加入上面的不拦截
.anyRequest().authenticated()
//再返回一个HttpSecurity http设置退出登录,退出登录默认地址为/logout
.and().logout().permitAll()
//添加退出操作和退出成功操作
.addLogoutHandler(myLogoutHandler).logoutSuccessHandler(myLogoutSuccessHandler)
//删除cookie
.deleteCookies("JSESSIONID")
//再返回一个HttpSecurity http设置登录,登录默认地址为/login
.and().formLogin()
//.loginPage("/mlogin.html")
//登录页面填写完成后的提交地址,默认是/login(SpringSecurity已经默认实现了此接口)
.loginProcessingUrl("/login")
//登录页面form表单中用户名框对应的name
.usernameParameter("username")
//登录页面form表单中密码框对应的name
.passwordParameter("password")
.successHandler(myLoginSuccessHandler).permitAll()
.failureHandler(myLoginFailureHandler)
//匿名用户访问资源时无权限处理器 .and().exceptionHandling().authenticationEntryPoint(myAuthExceptionEntryPoint)
//已登录用户权限不足处理器
.accessDeniedHandler(myAccessDeniedHandler);
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
注销操作处理器、注销成功处理器
//注销操作
@Component
public class MyLogoutHandler implements LogoutHandler {
@Override
public void logout(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) {
//或User user = (User) authentication.getPrincipal()拿到User对象
String name = authentication.getName();
System.out.println("用户登出" + name);
//authentication变量中获取当前用户信息。可以通过这个来实现具体想要的业务,比如记录用户下线退出时间、IP等等。
SecurityContextHolder.clearContext();
}
}
//注销成功处理器
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
User user = (User) authentication.getPrincipal();
HashMap<String, Object> map = new HashMap<>();
map.put("code", "480");
map.put("message", "用户退出成功" + user.getUsername());
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
测试后,访问/logout,成功退出并清空cookie,提示480代码。实际上可以不配置MyLogoutHandler,只需要MyLogoutSuccessHandler即可。
Session会话控制
Session创建
用户登录成功后,信息保存在服务器Session中。SpringSecurity提供了4种方式控制会话创建。
-
always:如果一个会话尚不存在,将始终创建一个会话。
-
ifRequired:仅在需要时创建会话,默认。
-
never:框架永远不会创建会话本身,但如果它已经存在,它将使用一个。
-
stateless:不会创建或使用任何会话,完全无状态。
在configure中进行会话管理,通过sessionManagement()配置。
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
...省略代码
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
;
...省略代码
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
如上面代码设定sessionCreationPolicy(SessionCreationPolicy.STATELESS),那么即使登录成功,访问接口仍然提示未登录,因为没有创建使用session。
实际开发中为默认sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)即可,或省略不写。
Session超时管理
在springboot中有2种配置设置超时方式。默认单位都是秒,除非显式写上单位,如server.servlet.session.timeout=60m那么为60分钟。
- server.servlet.session.timeout=60:在springboot应用程序层控制session的有效时长。
- spring.session.timeout=60:启用springsession来控制会话。此时server.servlet.session.timeout不起作用。如果是多节点共享session(比如springsession常用的是redis)那么有效解决session持久化和共享的问题。
以上两个是容器的超时时间,配置spring-session-reids中的超时时间可通过@EnableRedisHttpSession注解中的maxInactiveIntervalInSeconds=60配置(默认单位是秒)。【注意:容器超时或重启,但如果配置了redis中没超时,这样仍不需要重新登录。】
如果设定的时间小于1分钟,那么仍按照1分钟处理(即超时最短时间为1分钟)。
session无效/登录超时/session不存在的处理器
session无效/登录超时/session不存在的处理器,通过invalidSessionStrategy(myInvalidSessionStrategy)设置。
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
...省略代码
//session无效或超时处理器
.invalidSessionStrategy(myInvalidSessionStrategy)
...省略代码
;
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
自定义类实现InvalidSessionStrategy。
//session无效/登录超时/session不存在的处理器
@Component
public class MyInvalidSessionStrategy implements InvalidSessionStrategy {
@Override
public void onInvalidSessionDetected(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
map.put("code", "600");
map.put("message", "长时间无操作,请重新登录");
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
为了测试方便,设置session超时为60秒。
server.servlet.session.timeout=60
登录成功访问某接口后,无操作60秒后,提示长时间无操作,请重新登录。
注意:invalidSessionStrategy与用户未登录处理器AuthenticationEntryPoint功能类似,经测试发现对antMatchers.permitAll的配置仍然全部拦截,因此不建议设置invalidSessionStrategy,使用用户未登录处理器AuthenticationEntryPoint即可。以后有时间再验证下。
并发登录处理
限制同一用户在不同设备上的并发登录数量,超出数量后仍能登录但是会强迫之前登录设备下线。
maximumSessions设置为1,maxSessionsPreventsLogin设置为false。
限制同一用户在不同设备上的并发登录数量,超出数量后禁止新的设备登录。
maximumSessions设置为1,maxSessionsPreventsLogin设置为true。
即maximumSessions表示同一用户最大并行登录数量,maxSessionsPreventsLogin表示达到最大并行数量后,true为禁止新的登录,false为挤掉已有登录。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
public PasswordEncoder p;
@Autowired
public UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
public MyLoginSuccessHandler myLoginSuccessHandler;
@Autowired
public MyLoginFailureHandler myLoginFailureHandler;
@Autowired
public MyAuthExceptionEntryPoint myAuthExceptionEntryPoint;
@Autowired
public MyAccessDeniedHandler myAccessDeniedHandler;
@Autowired
public MyLogoutHandler myLogoutHandler;
@Autowired
public MyLogoutSuccessHandler myLogoutSuccessHandler;
@Autowired
public MyInvalidSessionStrategy myInvalidSessionStrategy;
@Autowired
public MySessionInformationExpiredStrategy mySessionInformationExpiredStrategy;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceImpl)
.passwordEncoder(p);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//拦截并认证所有的请求
http.authorizeRequests()
...省略代码
.and().sessionManagement() .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(mySessionInformationExpiredStrategy);
...省略代码
//开启跨域访问
http.cors().disable();
//关闭跨域攻击
http.csrf().disable();
}
}
自定义类SessionInformationExpiredStrategy实现SessionInformationExpiredStrategy。
//session失效/超出同账户并发登录数策略
@Component
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException {
HashMap<String, Object> map = new HashMap<>();
map.put("code", "610");
map.put("message", "您已在其他设备登录,请重新登录");
HttpServletResponse httpServletResponse = sessionInformationExpiredEvent.getResponse();
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
测试:
-
当maximumSessions(1).maxSessionsPreventsLogin(false):设备A登录后,访问/index正常;使用其他设备B登录后,设备A访问/index会提示"您已在其他设备登录,请重新登录"。
-
当maximumSessions(1).maxSessionsPreventsLogin(true):设备A登录后,访问/index正常;使用其他设备B登录后会提示未知错误。原因是在自定义登录失败Handler(MyLoginFailureHandler)中并没有捕获SessionAuthenticationException(Maximum sessions of 1 for this principal exceeded)。因此可以添加对这个异常的捕获。采用如下代码块后,超过用户数后会提示禁止登录。
else if(e instanceof SessionAuthenticationException && e.getMessage().startsWith("Maximum sessions")){
a = "您已在其他设备登录,禁止登录";
}