SpringBoot 集成 Spring Security(6)——登录管理

在本篇中,主要关注登录的管理,因此代码使用最原始版本的即可,即《SpringBoot集成Spring Security(1)——入门程序》源码即可。

源码地址:https://github.com/jitwxs/blog_sample

一、自定义认证成功、失败处理

有些时候我们想要在认证成功后做一些业务处理,例如添加积分;有些时候我们想要在认证失败后也做一些业务处理,例如记录日志。

在之前的文章中,关于认证成功、失败后的处理都是如下配置的:

1
2
3
4
5
6
7
8
9
10
11
12
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
// .antMatchers().permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
.failureUrl("/login/error")
// 设置登陆成功页
.defaultSuccessUrl("/")
.permitAll()
...;

failureUrl() 指定认证失败后Url,defaultSuccessUrl() 指定认证成功后Url。

我们可以通过设置 successHandler()failureHandler() 来实现自定义认证成功、失败处理。

PS:当我们设置了这两个后,需要删除 failureUrl()defaultSuccessUrl() 的设置,否则无法生效。

1.1 CustomAuthenticationSuccessHandler

自定义 CustomAuthenticationSuccessHandler 类来实现 AuthenticationSuccessHandler 接口,用来处理认证成功后逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private ObjectMapper objectMapper;

private Logger logger = LoggerFactory.getLogger(getClass());

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication Authentication) throws IOException, ServletException {
logger.info("登录成功");

response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}

onAuthenticationSuccess() 方法的第三个参数 Authentication 为认证后该用户的认证信息,这里只是简单的将其返回到前台。

1.2 CustomAuthenticationFailureHandler

自定义 CustomAuthenticationFailureHandler 类来实现 AuthenticationFailureHandler 接口,用来处理认证失败后逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;

private Logger logger = LoggerFactory.getLogger(getClass());

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登陆失败");

response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception));
}
}

onAuthenticationFailure()方法的第三个参数 exception 为认证失败所产生的异常,同样也是简单的返回到前台。

1.3 修改 WebSecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

...

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
// .antMatchers().permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler)
// .failureUrl("/login/error")
// 设置登陆成功页
// .defaultSuccessUrl("/")
.permitAll()
...;

// 关闭CSRF跨域
http.csrf().disable();
}

...
}
  1. 首先将 customAuthenticationSuccessHandlercustomAuthenticationFailureHandler注入进来。
  2. 配置 successHandler()failureHandler()
  3. 注释 failureUrl()defaultSuccessUrl()

1.4 运行程序

当我们认证成功后,发现“登陆成功”日志被打印出来,页面展示了用户的认证信息:

当我们认证失败后,发现“登陆失败”日志被打印出来,页面展示了认证失败的异常信息:

二、限制最大登录数

PS:从本节开始,因为和上一节自定义认证成功、失败处理无关,所以在 WebSecurityConfig 中注释掉刚刚设置的 successHandler 和 failureHandler,并放开 failureUrl 和 defaultSuccessUrl

修改 WebSecurityConfig 的 configure() 方法,新增关键代码如下:

1
2
3
4
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new CustomExpiredSessionStrategy())

其中:

  • maximumSessions(int):指定最大登录数。
  • maxSessionsPreventsLogin(boolean):是否保留已经登录的用户;为true,新用户无法登录;为false,旧用户被踢出。
  • expiredSessionStrategy(SessionInformationExpiredStrategy):旧用户被踢出后处理方法。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 如果有允许匿名的url,填在下面
// .antMatchers().permitAll()
.anyRequest().authenticated()
.and()
// 设置登陆页
.formLogin().loginPage("/login")
// 设置登陆成功页
.defaultSuccessUrl("/").permitAll()
// 自定义登陆用户名和密码参数,默认为username和password
// .usernameParameter("username")
// .passwordParameter("password")
.and()
.logout().and()
.sessionManagement()
.maximumSessions(1)
// 当达到maximumSessions时,是否保留已经登录的用户
.maxSessionsPreventsLogin(false)
// 当达到maximumSessions时,旧用户被踢出后的操作
.expiredSessionStrategy(new CustomExpiredSessionStrategy());

// 关闭CSRF跨域
http.csrf().disable();
}

maxSessionsPreventsLogin()可能不太好理解,这里我们先设为 false,这其实和 QQ 登录是一样的效果,登陆后之前账户被踢出。

编写 CustomExpiredSessionStrategy 实现 SessionInformationExpiredStrategy 接口,处理用户被踢出后的业务逻辑。你可以根据实际需求返回 JSON 数据或者是 Url:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CustomExpiredSessionStrategy implements SessionInformationExpiredStrategy {
private ObjectMapper objectMapper = new ObjectMapper();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

/**
* 根据需要返回 Url 或者 Json
* @author jitwxs
* @since 2018/11/29 18:46
*/
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>(16);
map.put("code", 0);
map.put("msg", "已经另一台机器登录,您被迫下线。" + event.getSessionInformation().getLastRequest());
// Map -> Json
String json = objectMapper.writeValueAsString(map);

event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write(json);

// 如果是跳转html页面,url代表跳转的地址
// redirectStrategy.sendRedirect(event.getRequest(), event.getResponse(), "url");
}
}

执行程序,打开两个浏览器,登录同一个账户。因为我设置了 maximumSessions(1),因此当你刷新先登录的那个浏览器时,被提示踢出了。

下面我们来测试下 maxSessionsPreventsLogin(true)时的情况,我们发现一个浏览器登录后,第二个浏览器无法登录,观察 Url 发现被强制跳转到登出页面:

这里使用我们在《SpringBoot集成Spring Security(3)——异常处理》中学习的异常处理,来确认下是不是因为已经达到最大登录而导致的错误。

修改 WebSecurityConfig 的 configure() 方法,添加一行 .failureUrl("/login/error") 来指定处理出错的 Url,在 Controller层中对异常信息进行捕捉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
public class LoginController {
...

@RequestMapping("/login/error")
public void loginError(HttpServletRequest request, HttpServletResponse response) {
response.setContentType("text/html;charset=utf-8");
AuthenticationException exception =
(AuthenticationException)request.getSession().getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
try {
response.getWriter().write(exception.toString());
}catch (IOException e) {
e.printStackTrace();
}
}
}

重新运行程序,错误信息显示的却是因为这个原因导致的错误:

三、踢出用户

下面来看下如何主动踢出一个用户。

创建一个 Bean,名为 SessionRegistry,这里我就简单的写在 WebSecurityConfig 中:

1
2
3
4
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}

修改 WebSecurityConfig 的 configure() 方法,添加一行 .sessionRegistry(sessionRegistry())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
...

@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
.sessionManagement()
.maximumSessions(1)
// 当达到maximumSessions时,是否保留已经登录的用户
.maxSessionsPreventsLogin(true)
// 当达到maximumSessions时,旧用户被踢出后的操作
.expiredSessionStrategy(new CustomExpiredSessionStrategy())
.sessionRegistry(sessionRegistry());

...
}

编写一个接口用于测试踢出用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Controller
public class LoginController {
...

@GetMapping("/kick")
@ResponseBody
public String removeUserSessionByUsername(@RequestParam String username) {
int count = 0;

// 获取session中所有的用户信息
List<Object> users = sessionRegistry.getAllPrincipals();
for (Object principal : users) {
if (principal instanceof User) {
String principalName = ((User)principal).getUsername();
if (principalName.equals(username)) {
// 参数二:是否包含过期的Session
List<SessionInformation> sessionsInfo = sessionRegistry.getAllSessions(principal, false);
if (null != sessionsInfo && sessionsInfo.size() > 0) {
for (SessionInformation sessionInformation : sessionsInfo) {
sessionInformation.expireNow();
count++;
}
}
}
}
}
return "操作成功,清理session共" + count + "个";
}
}
  1. sessionRegistry.getAllPrincipals(); 获取所有 principal 信息
  2. 通过 principal.getUsername 是否等于输入值,获取到指定用户的 principal
  3. sessionRegistry.getAllSessions(principal, false)获取该 principal 上的所有 session
  4. 通过 sessionInformation.expireNow() 使得 session 过期

运行程序,分别使用 admin 和 jitwxs 账户登录,admin账户访问 /kick?username=jitwxs 来踢出用户 jitwxs,jitwxs刷新页面,发现被踢出,通过下图可以发现实际上是调用了 expiredSessionStrategy(new CustomExpiredSessionStrategy())设置的方法。

文章作者: Jitwxs
文章链接: https://blog.jitwxs.cn/59f4016e.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来自 Jitwxs