Striveonger

vuePress-theme-reco Mr.Lee    2015 - 2025
Striveonger Striveonger
主页
分类
  • 文章
  • 笔记
  • 工具
标签
时间轴
author-avatar

Mr.Lee

264

Article

134

Tag

主页
分类
  • 文章
  • 笔记
  • 工具
标签
时间轴

Spring Security 自定义用户认证

vuePress-theme-reco Mr.Lee    2015 - 2025

Spring Security 自定义用户认证

Mr.Lee 2021-06-28 19:16:23 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);
    }
}
1
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;
}
1
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();
}
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
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;
            }
        };
    }
}
1
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>
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
35
36
37
38
39
40
41
42
43
44
45
46

要怎么做才能让Spring Security跳转到我们自己定义的登录页面呢? 很简单, 只需要在BrowserSecurityConfig的configure中添加一些配置:

.loginProcessingUrl("/login").loginPage("/login.html")
1

上面代码中:

  • .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();
    }
}
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

# 自定义成功与失败

自定义登录成功处理逻辑需要实现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");
            }
        };
    }
}
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
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());
    }
}
1
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());
    }
}
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
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();
    }
}
1
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();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

image-20210705142015503

参考文章:

  • https://mrbird.cc/Spring-Security-Authentication.html