赞
踩
继上一篇文章《OAuth2认证授权流程解析》,我们对OAuth2的4种认证模型的流程做了一一跟踪了解,我们知道当用户访问的资源需要认证之后,就会重定向到登录页面/login,此时就需要用户输入用户名和密码然后post方式提交到/login页面进行登录验证,如果验证通过则会跳转到原来的页面。
这里要说明的是OAuth2提供了默认的登录页面,当你访问资源需要认证时候,默认跳转到OAuth2的登录页面:
如果我们定义自己的页面,那么跳转后效果如下(虽然丑一点,不过您可以自己定制自己的样式):
如果你想定制自己的登录页面,我们首先要知道如下两方面:
要定制自己的登录页面,我们需要将自定义登录页面路径告知我们的security,那如何配置呢?重点就在websecurity安全配置类WebSecurityConfigurerAdapter子类中重载 “protected void configure(HttpSecurity http) throws Exception”方法,指定自己的登录页面路径,如下所示:
@Override protected void configure(HttpSecurity http) throws Exception { // 注册各类型的登录认证过滤器 http .addFilterBefore(openIdLoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(accessTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(phoneLoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(qrLoginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // 自定义开放url过滤器配置--无需鉴权 ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests(); registry.anyRequest().authenticated().and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/home") .permitAll() .and() .logout().permitAll() .and() .csrf().disable() .httpBasic(); }
这里,我们配置了各种过滤器和安全配置选项,我们可以忽略重点关注指定自定义的登录页面路径是"/login", 该路径我们可以在controller中在对应路径端点返回指定的view即可,或者我们不创建controller,而是通过mvc控制指定对应端点路径login的视图名称即可,如我的配置如下:
/** * @文件名称: WebMvcConfig.java * @功能描述: TODO(用一句话描述该文件做什么) * @版权信息: www.dondown.com * @编写作者: lixx2048@163.com * @开发日期: 2020年4月8日 * @历史版本: V1.0 */ @SuppressWarnings("deprecation") @Configuration public class WebMvcConfig extends WebMvcConfigurerAdapter{ @Override public void addViewControllers(ViewControllerRegistry registry) { // 前面是url路径,后面是视图路径,添加thymeleaf后自动配置prefix为/templates,suffix为.html registry.addViewController("/login").setViewName("/login"); registry.addViewController("/home").setViewName("/home"); registry.addViewController("/admin").setViewName("/admin"); } }
在SpringMVC配置中,我们配置了项目路径/login对应的视图名为/login,也就是对应我们的静态文件templates/login.html(springboot项目默认的静态目录有4个如static、template、public、resources,默认的文件名后缀为.html,这些可以通过配置文件修改)
经过以上配置之后,也就是说当用户访问需要授权的页面的时候,用户需要登录,跳转的登录页面路径是/login,对应自己的静态页面为login.html。所以,我们只需要在编辑静态资源目录下的login.html即可。 为了方便,我们引入thymeleaf渲染模板,使得在html中我们可以访问Controller返回的model数据。我们自定义登录页面实现代码如下:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <link th:src="@{/webjars/bootstrap/css/bootstrap.min.css}" rel="stylesheet"/> <title>自定义登录界面</title> </head> <body> <div class="container" align="center"> <!-- 对应后台的/login处理方法:注意这里是POST,拦截的GET的/login为登录页面跳转 --> <form th:action="@{/login}" method="POST"> <p th:if="${param.logout}" class="bg-warning">你已注销</p> <p th:if="${param.error}" class="bg-danger">用户名或密码错误</p> <input type="text" id="username" name="username" placeholder="用户名"/> <br/> <input type="password" id="password" name="password" placeholder="密码"/> <br/> <button class="btn btn-primary btn-lg" type="submit">登录</button> </form> </div> </body> </html>
我们可以定制自己的登录页的样式,制作一个漂亮的、满足个性化定制的登录页面出来,这个完全在自己样式控制中可以实现。
配置了自己的登录路径并编写了自己个性化登录页面,最终提交到哪里去?提交哪些参数?这是我们需要关注的问题。正如以上的form表单所写:
<form th:action="@{/login}" method="POST">
<p th:if="${param.logout}" class="bg-warning">你已注销</p>
<p th:if="${param.error}" class="bg-danger">用户名或密码错误</p>
<input type="text" id="username" name="username" placeholder="用户名"/>
<br/>
<input type="password" id="password" name="password" placeholder="密码"/>
<br/>
<button class="btn btn-primary btn-lg" type="submit">登录</button>
</form>
我们看到form表单提交的地址是/login,请求方式是POST,这是为什么????
原因可以通过文章《OAuth2认证授权流程解析》一文分析可以知道:用户名密码登录处理的过滤器是UsernamePasswordAuthenticationFilter, 它拦截的url正好是/login的post请求,其完整代码如下:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { // ~ Static fields/initializers // ===================================================================================== public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; private boolean postOnly = true; // ~ Constructors // =================================================================================================== public UsernamePasswordAuthenticationFilter() { super(new AntPathRequestMatcher("/login", "POST")); } // ~ Methods // ======================================================================================================== public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); String password = obtainPassword(request); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } /** * Enables subclasses to override the composition of the password, such as by * including additional values and a separator. * <p> * This might be used for example if a postcode/zipcode was required in addition to * the password. A delimiter such as a pipe (|) should be used to separate the * password and extended value(s). The <code>AuthenticationDao</code> will need to * generate the expected password in a corresponding manner. * </p> * * @param request so that request attributes can be retrieved * * @return the password that will be presented in the <code>Authentication</code> * request token to the <code>AuthenticationManager</code> */ protected String obtainPassword(HttpServletRequest request) { return request.getParameter(passwordParameter); } /** * Enables subclasses to override the composition of the username, such as by * including additional values and a separator. * * @param request so that request attributes can be retrieved * * @return the username that will be presented in the <code>Authentication</code> * request token to the <code>AuthenticationManager</code> */ protected String obtainUsername(HttpServletRequest request) { return request.getParameter(usernameParameter); } /** * Provided so that subclasses may configure what is put into the authentication * request's details property. * * @param request that an authentication request is being created for * @param authRequest the authentication request object that should have its details * set */ protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } /** * Sets the parameter name which will be used to obtain the username from the login * request. * * @param usernameParameter the parameter name. Defaults to "username". */ public void setUsernameParameter(String usernameParameter) { Assert.hasText(usernameParameter, "Username parameter must not be empty or null"); this.usernameParameter = usernameParameter; } /** * Sets the parameter name which will be used to obtain the password from the login * request.. * * @param passwordParameter the parameter name. Defaults to "password". */ public void setPasswordParameter(String passwordParameter) { Assert.hasText(passwordParameter, "Password parameter must not be empty or null"); this.passwordParameter = passwordParameter; } /** * Defines whether only HTTP POST requests will be allowed by this filter. If set to * true, and an authentication request is received which is not a POST request, an * exception will be raised immediately and authentication will not be attempted. The * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed * authentication. * <p> * Defaults to <tt>true</tt> but may be overridden by subclasses. */ public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getUsernameParameter() { return usernameParameter; } public final String getPasswordParameter() { return passwordParameter; } }
它会生成一个UsernamePasswordAuthenticationToken认证请求,最终提交给认证管理器authenticationManager进行认证,认证管理器会遍历所有的Provider进行逐一认证,此处能匹配的是DaoAuthenticationProvider(因为我们默认配置的是jdbcStore)。
public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); // Determine username // 获取用户名 String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { // 根据用户名提取用户信息 user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found"); // 是否隐藏用户未找到异常 if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { // 检查用户是否过期、锁定、禁用 preAuthenticationChecks.check(user); // 检查用户密码是否相等 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { // There was a problem, so try again after checking // we're using latest data (i.e. not from the cache) cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } // 后期验证 postAuthenticationChecks.check(user); if (!cacheWasUsed) { this.userCache.putUserInCache(user); } Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } // 成功后创建UsernamePasswordAuthenticationToken(用户对象,密码,权限)最后交给endpoint处理生成token return createSuccessAuthentication(principalToReturn, authentication, user); }
最终返回一个UsernamePasswordAuthenticationToken认证结果。我们看看认证成功后如何处理?我们查看过滤器UsernamePasswordAuthenticationFilter的父类AbstractAuthenticationProcessingFilter实现(会调用子类的attemptAuthentication方法)最终认证成功后处理:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; if (!requiresAuthentication(request, response)) { chain.doFilter(request, response); return; } if (logger.isDebugEnabled()) { logger.debug("Request is to process authentication"); } Authentication authResult; try { authResult = attemptAuthentication(request, response); if (authResult == null) { // return immediately as subclass has indicated that it hasn't completed // authentication return; } sessionStrategy.onAuthentication(authResult, request, response); } catch (InternalAuthenticationServiceException failed) { logger.error( "An internal error occurred while trying to authenticate the user.", failed); unsuccessfulAuthentication(request, response, failed); return; } catch (AuthenticationException failed) { // Authentication failed unsuccessfulAuthentication(request, response, failed); return; } // Authentication success if (continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } successfulAuthentication(request, response, chain, authResult); }
可以看到认证成功后也就是
authResult = attemptAuthentication(request, response);
返回了UsernamePasswordAuthenticationToken认证结果,父类做了几件事:
public void onAuthentication(Authentication authentication,
HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException {
for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Delegating to " + delegate);
}
delegate.onAuthentication(authentication, request, response);
}
}
这里集合中有一个ChangeSessionIdAuthenticationStrategy修改会话id认证策略会更新当前会话的认证信息为已认证。
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { if (logger.isDebugEnabled()) { logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult); } SecurityContextHolder.getContext().setAuthentication(authResult); rememberMeServices.loginSuccess(request, response, authResult); // Fire event if (this.eventPublisher != null) { eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( authResult, this.getClass())); } successHandler.onAuthenticationSuccess(request, response, authResult); }
它处理逻辑是:
1、更新当前的安全上下文对象的认证信息;
2、更新rememberMeServices信息为登录成功;
3、发布InteractiveAuthenticationSuccessEvent交互认证成功事件;
4、调用successHandler(SavedRequestAwareAuthenticationSuccessHandler)的onAuthenticationSuccess
关键就是在最后一步,情况是这样的
1、首先我在浏览器中访问的是:http://localhost:15003/admin
2、后台服务器经过投票选举发现是拒绝访问,所以后台直接通知浏览器跳转到/login页面
3、login页面返回的是自定义页面视图login.html
4、用户输入用户名、密码提交给你/login(post请求方式)
5、认证成功,通知浏览器重新重定向到:http://localhost:15003/admin
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { // 通过请求获取对应session然后在session查找上一次请求(对应的键的名称为SPRING_SECURITY_SAVED_REQUEST) SavedRequest savedRequest = requestCache.getRequest(request, response); // 上一次请求为null说明是直接请求而不是无权限导致的重定向 if (savedRequest == null) { super.onAuthenticationSuccess(request, response, authentication); return; } // 获取请求的URL参数 String targetUrlParameter = getTargetUrlParameter(); if (isAlwaysUseDefaultTargetUrl() || (targetUrlParameter != null && StringUtils.hasText(request .getParameter(targetUrlParameter)))) { requestCache.removeRequest(request, response); super.onAuthenticationSuccess(request, response, authentication); return; } // 清除上一次请求信息(本次已经处理) clearAuthenticationAttributes(request); // Use the DefaultSavedRequest URL // 获取上一次请求地址作为重定向地址: http://localhost:15003/admin String targetUrl = savedRequest.getRedirectUrl(); logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl); // 认证成功后使得浏览器重定向到上一次想要访问的地址http://localhost:15003/admin getRedirectStrategy().sendRedirect(request, response, targetUrl); }
所以,我们查看以上代码的逻辑,认证成功之后,如果有上一次的cache的访问记录了则使浏览器重新重定向到上一次的请求地址。
public void sendRedirect(HttpServletRequest request, HttpServletResponse response,
String url) throws IOException {
// 获取重定向地址
String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
if (logger.isDebugEnabled()) {
logger.debug("Redirecting to '" + redirectUrl + "'");
}
// 发送重定向响应
response.sendRedirect(redirectUrl);
}
源码获取、合作、技术交流请获取如下联系方式:
QQ交流群:961179337
微信账号:lixiang6153
公众号:IT技术快餐
电子邮箱:lixx2048@163.com
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。