Spring Security 自定义用户认证
从上一篇文章中, 可以看到通过DaoAuthenticationProvider.retrieveUser()
方法. 拿到的UserDetails
对象. 进而做的一系列的校验工作. 我们今天就先看看这个类吧
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
prepareTimingAttackProtection();
try {
// 通过用户名来获取 UserDetails
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
} catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
} catch (InternalAuthenticationServiceException ex) {
throw ex;
} catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 自定义认证过程
可以看到 getUserDetailsService()
方法, 拿到的是UserDetailsService
接口
// 如果我们自己实现接口, 注入到IOC中, 不就可以实现自己的用户认证了嘛...
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
2
3
4
loadUserByUsername()
方法返回一个UserDetail
对象, 该对象也是一个接口, 包含一些用于描述用户信息的方法
public interface UserDetails extends Serializable {
/**
* 获取用户包含的权限, 返回权限集合, 权限是一个继承了GrantedAuthority的对象
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 获取用户密码
*/
String getPassword();
/**
* 获取用户名
*/
String getUsername();
/**
* 用于判断账户是否未过期, 未过期返回true反之返回false
*/
boolean isAccountNonExpired();
/**
* 用于判断账户是否未锁定
*/
boolean isAccountNonLocked();
/**
* 用于判断用户凭证是否没过期, 即密码是否未过期
*/
boolean isCredentialsNonExpired();
/**
* 用于判断用户是否可用
*/
boolean isEnabled();
}
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
35
36
实际中我们可以自定义UserDetails
接口的实现类, 也可以直接使用Spring Security提供的UserDetails
接口实现 org.springframework.security.core.userdetails.User
首先创建一个AuthUser
对象, 用于存放模拟的用户数据(实际中一般从数据库获取, 这里为了方便直接模拟):
public class UserHandler {
@Bean
public UserDetailsService userDetailsService() {
String username_def = "strivonger";
// 123456 的BCrypt密文
String password_def = "$2a$10$KP5bBYzfq7ixKJEsgf5SMe0rn08uRqYKcrF/MDIZvr4SztzqY89Fe";
return new UserDetailsService() {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO: 模拟从数据库中根据用户名读出用户信息. 数据库中的密码是密文存储的, 所以读出来也会是密文的哦~
AuthUser user = new AuthUser();
user.setUsername(username_def);
user.setPassword(password_def);
return user;
}
};
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 替换默认登录页
默认的登录页面过于简陋, 我们可以自己定义一个登录页面. 为了方便起见, 我们直接在src/main/resources/static
目录下定义一个login.html
(不需要Controller跳转):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>登录</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<div class="container">
<form class="form-signin" method="post" action="/login">
<h2 class="form-signin-heading">登录</h2>
<div id="alert" class="alert alert-danger collapse" role="alert">用户名或密码错误</div>
<p>
<label for="username" class="sr-only">用户名</label>
<input type="text" id="username" name="username" class="form-control" placeholder="用户名" required autofocus />
</p>
<p>
<label for="password" class="sr-only">密码</label>
<input type="password" id="password" name="password" class="form-control" placeholder="密码" required />
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">登录</button>
</form>
</div>
<script type="text/javascript">
function getQueryVariable(variable) {
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i=0;i<vars.length;i++) {
var pair = vars[i].split("=");
if(pair[0] == variable){return pair[1];}
}
return(false);
}
window.onload = function() {
if(getQueryVariable("error")) {
document.getElementById("alert").setAttribute("class", "alert alert-danger");
}
}
</script>
</body>
</html>
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
35
36
37
38
39
40
41
42
43
44
45
46
要怎么做才能让Spring Security跳转到我们自己定义的登录页面呢? 很简单, 只需要在BrowserSecurityConfig
的configure
中添加一些配置:
.loginProcessingUrl("/login").loginPage("/login.html")
上面代码中:
.loginPage("/login.html")
: 指定了跳转到登录页面的请求URL.loginProcessingUrl("/login")
: 对应登录页面form表单的action="/login"
.antMatchers("/login.html").permitAll()
表示跳转到登录页面的请求不被拦截, 否则会进入无限循环
# 处理成功和失败
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 为login添加许可
.antMatchers("/login", "/login.html").permitAll()
// 验证所有请求
.anyRequest().authenticated()
.and()
// 允许用户使用表单登录进行身份认证
.formLogin()
.loginProcessingUrl("/login").loginPage("/login.html")
.successHandler(successHandler) // 登录成功后的处理
.failureHandler(failHandler).permitAll() // 登录失败后的处理
.and()
// 允许用户使用HTTP基本认证
.httpBasic()
.and()
// 跨域访问(不开CSRF, 页面无法正常登录)
.csrf().disable();
}
}
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
# 自定义成功与失败
自定义登录成功处理逻辑需要实现org.springframework.security.web.authentication.AuthenticationSuccessHandler
的onAuthenticationSuccess
方法, 自定义登录失败处理逻辑需要实现org.springframework.security.web.authentication.AuthenticationFailureHandler
的onAuthenticationFailure
方法:
@Component
public class LoginHandler {
@Bean
public AuthenticationSuccessHandler successHandler() {
return (request, response, authentication) -> {
String type = request.getParameter("type");
if ("json".equals(type)) {
response.setContentType("application/ison; charset=UTF-8");
PrintWriter out = response.getWriter();
String info = new Gson().toJson(Dict.create().set("status", "success").set("message", "登录成功"));
out.write(info);
} else {
log.info("进入自定义的成功页面跳转");
response.sendRedirect("/home.html");
}
};
}
@Bean
public AuthenticationFailureHandler failureHandler() {
return (request, response, exception) -> {
String type = request.getParameter("type");
if ("json".equals(type)) {
response.setContentType("application/ison; charset=UTF-8");
PrintWriter out = response.getWriter();
String info = new Gson().toJson(Dict.create().set("status", "failure").set("message", "登录失败"));
out.write(info);
} else {
log.info("进入自定义的失败页面跳转");
response.sendRedirect("/login.html?error=true");
}
};
}
}
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
35
# 添加图形验证码
# 1. 加入验证码接口
@RestController
public class CaptchaController {
public static final String SESSION_KEY_CAPTCHA_CODE = "SESSION_KEY_CAPTCHA_CODE";
@GetMapping("/captcha.jpeg")
public void captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 100, 4, 30);
request.getSession(true).setAttribute(SESSION_KEY_CAPTCHA_CODE, captcha.getCode());
captcha.write(response.getOutputStream());
}
}
2
3
4
5
6
7
8
9
10
11
12
# 2. 加入验证码过滤器
@Component
public class CaptchaFilter extends OncePerRequestFilter {
private final AntPathRequestMatcher loginMatcher = new AntPathRequestMatcher("/login", "POST");
private final String CAPTCHA = "captcha";
// 默认开启验证码校验
@Value("${app.captcha.enable:true}")
private boolean enable;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (enable && loginMatcher.matches(request) && !validate(request)) {
// TODO: 稍后会统一处理 throws. 临时写回内容
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter out = response.getWriter();
String info = JSONBuilder.builder().toJSONString(Dict.create().set("status", "failure").set("message", "验证码不正确"));
out.write(info);
return;
}
filterChain.doFilter(request, response);
}
/**
* 验证码, 校验
*/
private boolean validate(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (Objects.isNull(session)) {
return false;
}
Object code = session.getAttribute(CaptchaController.SESSION_KEY_CAPTCHA_CODE);
if (Objects.nonNull(code)) {
session.removeAttribute(CaptchaController.SESSION_KEY_CAPTCHA_CODE);
}
String validCode = request.getParameter(CAPTCHA);
return Objects.nonNull(code) && Objects.nonNull(validCode) && validCode.trim().equalsIgnoreCase(code.toString().trim());
}
}
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
35
36
37
38
39
40
41
# 3. 使过滤器生效
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CaptchaFilter captchaFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class).authorizeRequests()
// 为login添加许可
.antMatchers("/login", "/login.html", "/captcha.jpeg").permitAll()
// 此处略去8万字...^_^
// 跨域访问
.csrf().disable();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在WebSecurityConfig
中加入 addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
. 使过滤器生效. 并且在校验用户名与密码之前…也是合理的, 验证码都没有通过, 就不会走数据库, 去查用户信息了
# remember-me
Security中,默认是使用PersistentTokenRepository的子类InMemoryTokenRepositoryImpl,将token放在内存中.
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CaptchaFilter captchaFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class).authorizeRequests()
// 此处略去8万字...^_^
// 对`记住我`的处理
.rememberMe()
// 跨域访问
.csrf().disable();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
参考文章:
- https://mrbird.cc/Spring-Security-Authentication.html