700字范文,内容丰富有趣,生活中的好帮手!
700字范文 > 【Spring Security】Spring Boot + Spring Security 实现自动登录功能

【Spring Security】Spring Boot + Spring Security 实现自动登录功能

时间:2018-09-02 03:46:00

相关推荐

【Spring Security】Spring Boot + Spring Security 实现自动登录功能

spring security 如何实现安全的自动登录

自动登录是我们在软件开发时一个非常常见的功能,很多网站我们在登录的时候都会有记住密码选项,毕竟总让用户输入用户名密码是一件很麻烦的事。自动登录功能就是,用户在登录成功后,在某一段时间内,如果用户关闭了浏览器并重新打开,或者服务器重启了,都不需要用户重新登录了,用户依然可以直接访问接口数据。作为一个常见的功能,我们的 Spring Security 肯定也提供了相应的支持,本文我们就来看下 Spring Security 中如何实现这个功能。这个功能实现起来简单,但是还是会涉及到很多细节,我们分别为两段介绍,一个是实现自动登录,一个是自动登录信息安全保存。

自动登录如何实现

首先,要实现记住我这个功能,其实只需要其实只需要在 Spring Security 的配置中,添加如下代码即可:

@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().rememberMe() .and().csrf().disable();}

大家看到,这里只需要添加一个.rememberMe()即可,自动登录功能就成功添加进来了。

我们查看源码,可以发现增加了

Remember me on this computer.

,如果我们自定义登录页面,则需要在页面上增加该remember-me 选项。于是我们在我们新页面上增加了改记住密码选项,访问登录页面后,我们查看登录后携带了Set-Cookie:remember-me=c3VtbWVyOjE2MjExNDEzNjU4MTc6NzQ1ZDVjMDY5MDJmYTQ5YmM3YWY5OTAwMjQ3MTgxNmQ; Max-Age=1209600; Expires=Sun, 16-May- 05:02:45 GMT; Path=/; HttpOnly

接下来,我们关闭浏览器,再重新打开浏览器。正常情况下,浏览器关闭再重新打开,如果需要再次访问 hello 接口,就需要我们重新登录了。但是此时,我们再去访问 hello 接口,发现不用重新登录了,直接就能访问到,这就说明我们的 RememberMe 配置生效了(即下次自动登录功能生效了)。

按理说,浏览器关闭再重新打开,就要重新登录,现在竟然不用登录了,那么这个功能到底是怎么实现的呢?首先我们来分析一下cookie 中多出来的这个 remember-me,这个值一看就是一个 Base64 转码后的字符串,我们可以使用网上的一些在线工具来解码,可以自己简单写两行代码来解码:

@Testvoid contextLoads() throws UnsupportedEncodingException {String s = new String(Base64.getDecoder().decode("c3VtbWVyOjE2MjExNDQyODcwMTA6NTVjNTNhNDY2YTg4NWQ3ZWY4YWU0YWYyZmIyNTliMmY"), "UTF-8");System.out.printf("s = %s" , s);}

执行这段代码,输出结果如下:

s = summer:1621144287010:55c53a466a885d7ef8ae4af2fb259b2f

可以看到,这段 Base64 字符串实际上用:隔开,分成了三部分:

第一段是用户名,这个无需质疑。第二段看起来是一个时间戳,我们通过在线工具或者 Java 代码解析后发现,这是一个两周后的数据。第三段我就不卖关子了,这是使用 MD5 散列函数算出来的值,他的明文格式是username + ":" + tokenExpiryTime + ":" + password + ":" + key,最后的 key 是一个散列盐值,可以用来防治令牌被修改。

了解到 cookie 中 remember-me 的含义之后,那么我们对于记住我的登录流程也就很容易猜到了了。

在浏览器关闭后,并重新打开之后,用户再去访问 hello 接口,此时会携带着 cookie 中的 remember-me 到服务端,服务到拿到值之后,可以方便的计算出用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。

流程就是这么个流程,接下来我们通过分析源码来验证一下这个流程对不对。

源码分析

接下来,我们通过源码来验证一下我们上面说的对不对。这里主要从两个方面来介绍,一个是 remember-me 这个令牌生成的过程,另一个则是它解析的过程。

生成

生成的核心处理方法在:TokenBasedRememberMeServices#onLoginSuccess

@Overridepublic void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,Authentication successfulAuthentication) {String username = retrieveUserName(successfulAuthentication);String password = retrievePassword(successfulAuthentication);if (!StringUtils.hasLength(password)) {UserDetails user = getUserDetailsService().loadUserByUsername(username);password = user.getPassword();}int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);long expiryTime = System.currentTimeMillis();expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);String signatureValue = makeTokenSignature(expiryTime, username, password);setCookie(new String[] {username, Long.toString(expiryTime), signatureValue },tokenLifetime, request, response);}protected String makeTokenSignature(long tokenExpiryTime, String username,String password) {String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();MessageDigest digest;digest = MessageDigest.getInstance("MD5");return new String(Hex.encode(digest.digest(data.getBytes())));}

这段方法的逻辑其实很好理解:

首先从登录成功的 Authentication 中提取出用户名/密码。由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。再接下来去获取令牌的有效期,令牌有效期默认就是两周。再接下来调用 makeTokenSignature 方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。

关于第四点,我这里再说一下。由于我们自己没有设置 key,key 默认值是一个 UUID 字符串,这样会带来一个问题,就是如果服务端重启,这个 key 会变,这样就导致之前派发出去的所有 remember-me 自动登录令牌失效,所以,我们可以指定这个 key。指定方式如下:

@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().rememberMe().key("wavefar software").and().csrf().disable();}

如果自己配置了 key,「即使服务端重启,即使浏览器打开再关闭」,也依然能够访问到 hello 接口。这是 remember-me 令牌生成的过程。至于是如何走到 onLoginSuccess 方法的,这里可以给大家稍微提醒一下思路:

AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication -> AbstractRememberMeServices#loginSuccess -> TokenBasedRememberMeServices#onLoginSuccess。

解析

那么当用户关掉并打开浏览器之后,重新访问 /hello 接口,此时的认证流程又是怎么样的呢?我们之前说过,Spring Security 中的一系列功能都是通过一个过滤器链实现的,RememberMe 这个功能当然也不例外。Spring Security 中提供了RememberMeAuthenticationFilter 类专门用来做相关的事情,我们来看下 RememberMeAuthenticationFilter 的 doFilter 方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) req;HttpServletResponse response = (HttpServletResponse) res;if (SecurityContextHolder.getContext().getAuthentication() == null) {Authentication rememberMeAuth = rememberMeServices.autoLogin(request,response);if (rememberMeAuth != null) {rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);onSuccessfulAuthentication(request, response, rememberMeAuth);if (this.eventPublisher != null) {eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));}if (successHandler != null) {successHandler.onAuthenticationSuccess(request, response,rememberMeAuth);return;}}chain.doFilter(request, response);}else {chain.doFilter(request, response);}}

可以看到,就是在这里实现的。这个方法最关键的地方在于,如果从 SecurityContextHolder 中无法获取到当前登录用户实例,那么就调用 rememberMeServices.autoLogin 逻辑进行登录,我们来看下这个方法:

public final Authentication autoLogin(HttpServletRequest request,HttpServletResponse response) {String rememberMeCookie = extractRememberMeCookie(request);if (rememberMeCookie == null) {return null;}logger.debug("Remember-me cookie detected");if (rememberMeCookie.length() == 0) {logger.debug("Cookie was empty");cancelCookie(request, response);return null;}UserDetails user = null;try {String[] cookieTokens = decodeCookie(rememberMeCookie);user = processAutoLoginCookie(cookieTokens, request, response);userDetailsChecker.check(user);logger.debug("Remember-me cookie accepted");return createSuccessfulAuthentication(request, user);}catch (CookieTheftException cte) {throw cte;}cancelCookie(request, response);return null;}

可以看到,这里就是提取出 cookie 信息,并对 cookie 信息进行解码,解码之后,再调用 processAutoLoginCookie 方法去做校验,processAutoLoginCookie 方法的代码我就不贴了,核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。好了,这里的流程我也根据大家大致上梳理了一下。

大家可能已经发现,如果我们开启了 RememberMe 功能,最最核心的东西就是放在 cookie 中的令牌了,这个令牌突破了 session 的限制,即使服务器重启、即使浏览器关闭又重新打开,只要这个令牌没有过期,就能访问到数据。一旦令牌丢失,别人就可以拿着这个令牌随意登录我们的系统了,这是一个非常危险的操作。但是实际上这是一段悖论,为了提高用户体验(少登录),我们的系统不可避免的引出了一些安全问题,不过我们可以通过技术将安全风险降低到最小。那么如何让我们的 RememberMe 功能更加安全呢?接下来介绍一下–持久化令牌方案。

自动登录信息安全保存

我们提到了 Spring Boot 自动登录存在的一些安全风险,在实际应用中,我们肯定要把这些安全风险降到最低,如何降低安全风险的问题。降低安全风险,我主要从两个方面来给大家介绍:

持久化令牌方案二次校验

持久化令牌就是在基本的自动登录功能基础上,又增加了新的校验参数,来提高系统的安全性,这一些都是由开发者在后台完成的,对于用户来说,登录体验和普通的自动登录体验是一样的。

在持久化令牌中,新增了两个经过 MD5 散列函数计算的校验参数,一个是 series,另一个是 token。其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新,而 token 只要有新的会话,就会重新生成,这样就可以避免一个用户同时在多端登录,就像手机 QQ ,一个手机上登录了,就会踢掉另外一个手机的登录,这样用户就会很容易发现账户是否泄漏(如何禁止多端登录,其实就可以借鉴这里的思路)。

持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices 中,上面我们刚讲到的自动化登录具体的处理类是在 TokenBasedRememberMeServices 中,它们有一个共同的父类:

而用来保存令牌的处理类则是 PersistentRememberMeToken,该类的定义也很简洁命令:

public class PersistentRememberMeToken {private final String username;private final String series;private final String tokenValue;private final Date date;//省略 getter}

这里的 Date 表示上一次使用自动登录的时间。

代码演示

接下来,我通过代码来给大家演示一下持久化令牌的具体用法。

首先我们需要一张表来记录令牌信息,这张表我们可以完全自定义,也可以使用系统默认提供的 JDBC 来操作,如果使用默认的 JDBC,即 JdbcTokenRepositoryImpl,我们可以来分析一下该类的定义:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implementsPersistentTokenRepository {public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "+ "token varchar(64) not null, last_used timestamp not null)";public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";}

根据这段 SQL 定义,我们就可以分析出来表的结构,这里给出一段 SQL 脚本:

CREATE TABLE `persistent_logins` (`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`series`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

首先我们在数据库中准备好这张表。

既然要连接数据库,我们还需要准备 jdbc 和 mysql 依赖,如下:

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency>

然后修改 application.properties ,配置数据库连接信息:

spring.datasource.url=jdbc:mysql://localhost:3306/db_user?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghaispring.datasource.username=rootspring.datasource.password=123456

接下来,我们修改 SecurityConfig,如下:

@AutowiredDataSource dataSource;@BeanJdbcTokenRepositoryImpl jdbcTokenRepository() {JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();jdbcTokenRepository.setDataSource(dataSource);return jdbcTokenRepository;}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().rememberMe().key("wavefar software").tokenRepository(jdbcTokenRepository()).and().csrf().disable();}

提供一个 JdbcTokenRepositoryImpl 实例,并给其配置 DataSource 数据源,最后通过 tokenRepository 将 JdbcTokenRepositoryImpl 实例纳入配置中。OK,做完这一切,我们就可以测试了。

测试

我们还是先去访问/hello接口,此时会自动跳转到登录页面,然后我们执行登录操作,记得勾选上“记住我”这个选项,登录成功后,我们可以重启服务器、然后关闭浏览器再打开,再去访问 /hello 接口,发现依然能够访问到,说明我们的持久化令牌配置已经生效。

查看 remember-me 的令牌,如下:

ZDk4Skk5MVlJTDhrSmJqZ3NNanB1USUzRCUzRDpZaURhYWtmRk5hYTRFRmFwc2glMkZZMFElM0QlM0Q

这个令牌经过解析之后,格式如下:

//通过base64解码后字符串如下String s = new String(Base64.getDecoder().decode("ZDk4Skk5MVlJTDhrSmJqZ3NNanB1USUzRCUzRDpZaURhYWtmRk5hYTRFRmFwc2glMkZZMFElM0QlM0Q"), "UTF-8");System.out.printf("s = %s" , s);//d98JI91YIL8kJbjgsMjpuQ%3D%3D:YiDaakfFNaa4EFapsh%2FY0Q%3D%3D

这其中,%3D 表示=,%2F表示/所以上面的字符实际上可以翻译成下面这样:

d98JI91YIL8kJbjgsMjpuQ==:YiDaakfFNaa4EFapsh/Y0Q==

此时,查看数据库,我们发现之前的表中生成了一条记录:

mysql> select * from persistent_logins;+----------+--------------------------+--------------------------+---------------------+| username | series | token| last_used |+----------+--------------------------+--------------------------+---------------------+| summer | d98JI91YIL8kJbjgsMjpuQ== | YiDaakfFNaa4EFapsh/Y0Q== | -05-02 15:38:38 |+----------+--------------------------+--------------------------+---------------------+1 row in set (0.00 sec)

数据库中的记录和我们看到的 remember-me 令牌解析后是一致的。

源码分析

这里的源码分析和上面的流程基本一致,只不过实现类变了,也就是生成令牌/解析令牌的实现变了,所以这里我主要和大家展示不一样的地方,流程问题,大家可以参考上面源码介绍。这次的实现类主要是:PersistentTokenBasedRememberMeServices,我们先来看里边几个和令牌生成相关的方法:

protected void onLoginSuccess(HttpServletRequest request,HttpServletResponse response, Authentication successfulAuthentication) {String username = successfulAuthentication.getName();PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(), generateTokenData(), new Date());tokenRepository.createNewToken(persistentToken);addCookie(persistentToken, request, response);}protected String generateSeriesData() {byte[] newSeries = new byte[seriesLength];random.nextBytes(newSeries);return new String(Base64.getEncoder().encode(newSeries));}protected String generateTokenData() {byte[] newToken = new byte[tokenLength];random.nextBytes(newToken);return new String(Base64.getEncoder().encode(newToken));}private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,HttpServletResponse response) {setCookie(new String[] {token.getSeries(), token.getTokenValue() },getTokenValiditySeconds(), request, response);}

可以看到:

在登录成功后,首先还是获取到用户名,即 username。接下来构造一个 PersistentRememberMeToken 实例,generateSeriesData 和 generateTokenData 方法分别用来获取 series 和 token,具体的生成过程实际上就是调用 SecureRandom 生成随机数再进行 Base64 编码,不同于我们以前用的 Math.random 或者 java.util.Random 这种伪随机数,SecureRandom 则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。调用 tokenRepository 实例中的 createNewToken 方法,tokenRepository 实际上就是我们一开始配置的 JdbcTokenRepositoryImpl,所以这行代码实际上就是将 PersistentRememberMeToken 存入数据库中。最后 addCookie,大家可以看到,就是添加了 series 和 token。

这是令牌生成的过程,还有令牌校验的过程,也在该类中,方法是:processAutoLoginCookie:

protected UserDetails processAutoLoginCookie(String[] cookieTokens,HttpServletRequest request, HttpServletResponse response) {final String presentedSeries = cookieTokens[0];final String presentedToken = cookieTokens[1];PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);if (!presentedToken.equals(token.getTokenValue())) {tokenRepository.removeUserTokens(token.getUsername());throw new CookieTheftException(messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen","Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));}if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {throw new RememberMeAuthenticationException("Remember-me login has expired");}PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), generateTokenData(), new Date());tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),newToken.getDate());addCookie(newToken, request, response);return getUserDetailsService().loadUserByUsername(token.getUsername());}

这段逻辑也比较简单:

首先从前端传来的 cookie 中解析出 series 和 token。根据 series 从数据库中查询出一个 PersistentRememberMeToken 实例。如果查出来的 token 和前端传来的 token 不相同,说明账号可能被人盗用(别人用你的令牌登录之后,token 会变)。此时根据用户名移除相关的 token,相当于必须要重新输入用户名密码登录才能获取新的自动登录权限。接下来校验 token 是否过期。构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token(这就是我们文章开头说的,新的会话都会对应一个新的 token)。将新的令牌重新添加到 cookie 中返回。根据用户名查询用户信息,再走一波登录流程。

OK,这里和小伙伴们简单理了一下令牌生成和校验的过程。

二次校验

相比于上面持久化令牌的方式其实已经安全很多了,但是依然存在用户身份被盗用的问题,这个问题实际上很难完美解决,我们能做的,只能是当发生用户身份被盗用这样的事情时,将损失降低到最小。因此,我们来看下另一种方案,就是二次校验。二次校验这块,实现起来要稍微复杂一点,我先来和大家说说思路。

为了让用户使用方便,我们开通了自动登录功能,但是自动登录功能又带来了安全风险,一个规避的办法就是如果用户使用了自动登录功能,我们可以只让他做一些常规的不敏感操作,例如数据浏览、查看,但是不允许他做任何修改、删除操作,如果用户点击了修改、删除按钮,我们可以跳转回登录页面,让用户重新输入密码确认身份,然后再允许他执行敏感操作。

这个功能在 Shiro 中有一个比较方便的过滤器可以配置,Spring Security 当然也一样,例如我现在提供三个访问接口:

@RestControllerpublic class HelloController {@GetMapping("/hello")public String hello() {return "hello";}@GetMapping("/admin")public String admin() {return "admin";}@GetMapping("/rememberme")public String rememberme() {return "rememberme";}}

第一个 /hello 接口,只要认证后就可以访问,无论是通过用户名密码认证还是通过自动登录认证,只要认证了,就可以访问。第二个 /admin 接口,必须要用户名密码认证之后才能访问,如果用户是通过自动登录认证的,则必须重新输入用户名密码才能访问该接口。第三个 /rememberme 接口,必须是通过自动登录认证后才能访问,如果用户是通过用户名/密码认证的,则无法访问该接口。

好了,我们来看下接口的访问要怎么配置:

@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/rememberme").rememberMe().antMatchers("/admin").fullyAuthenticated().anyRequest().authenticated().and().formLogin().and().rememberMe().key("wavefar software").tokenRepository(jdbcTokenRepository()).and().csrf().disable();}

可以看到:

/rememberme 接口是需要 rememberMe 才能访问。/admin 是需要 fullyAuthenticated,fullyAuthenticated 不同于 authenticated,fullyAuthenticated 不包含自动登录的形式,而 authenticated 包含自动登录的形式。最后剩余的接口(/hello)都是 authenticated 就能访问。

OK,配置完成后,重启测试,测试过程我就不再赘述了。大家自己访问地址测试吧!

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。