1:概述
1.1:认证和授权的区别
认证就是证明你是谁
,而授权是你能做什么
,比如你要连接mysql数据库,此时需要用户名和密码,这个过程就是认证,而登陆之后你可以做什么操作,比如创建表,删除表,查询数据,等,这就是授权。其实这个过程对于所有的系统的都是通用的,你登录系统需要录入认证信息来登录,这就是认证,登录之后你能看到什么,干什么,这就是授权。
在系统中,认证对应的应该就是登录功能,而授权对应的就是权限控制功能。本文我们要分析的springsecurity就可以提供二者的功能。
2:实例程序
2.1:引入相关起步依赖
<!-- 通过依赖管理来统一springboot相关版本和简化pom声明 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.1.14.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- springMVC起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
2.2:main
@SpringBootApplication
public class SpringSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityApplication.class, args);
}
}
2.3:配置文件
application.yml
:
spring:
# Spring Security 配置项,对应 org.springframework.boot.autoconfigure.security.SecurityProperties 配置类
# 即这里配置的信息会通过该类进行接收和封装,后续获取该类的信息也是通过该类了
security:
# 配置默认的 InMemoryUserDetailsManager 的用户账号与密码。
user:
name: user # 账号
password: user # 密码SecurityProperties
roles: ADMIN # 拥有角色
这里的配置会被封装到类org.springframework.boot.autoconfigure.security.SecurityProperties
,源码如下:
// org.springframework.boot.autoconfigure.security.SecurityProperties
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
public static final int IGNORED_ORDER = Ordered.HIGHEST_PRECEDENCE;
public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
// 内部静态类,过滤认证
private final Filter filter = new Filter();
// 内部静态类,封装用户名,密码,角色等信息
private User user = new User();
public User getUser() {
return this.user;
}
public Filter getFilter() {
return this.filter;
}
public static class Filter {
private int order = DEFAULT_FILTER_ORDER;
private Set<DispatcherType> dispatcherTypes = new HashSet<>(
Arrays.asList(DispatcherType.ASYNC, DispatcherType.ERROR, DispatcherType.REQUEST));
public int getOrder() {
return this.order;
}
public void setOrder(int order) {
this.order = order;
}
public Set<DispatcherType> getDispatcherTypes() {
return this.dispatcherTypes;
}
public void setDispatcherTypes(Set<DispatcherType> dispatcherTypes) {
this.dispatcherTypes = dispatcherTypes;
}
}
// 用户对象
public static class User {
// 默认的用户名,这里是user
private String name = "user";
// 默认的密码,这里是通过UUID生成的随机字符串
private String password = UUID.randomUUID().toString();
// 授权给用户的角色列表
private List<String> roles = new ArrayList<>();
// 标记密码是否为生成的
private boolean passwordGenerated = true;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
// 如果是使用了用户的密码则将密码是否为生成的标记置为false
this.passwordGenerated = false;
this.password = password;
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = new ArrayList<>(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
}
其对应的自动配置类是org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
,其在spring.factories
中的配置如下:
该类源码如下:
// org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
/*
提供默认的认证管理类,InMemoryUserDetailsManager,从条件注解@ConditionalOnMissingBean({ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class }),如果是提供了类型为“{ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class }”三者之一的bean则该自动配置类条件就不成立,就不会加载对应的@Bean注解对应的方法,即就不会创建InMemoryUserDetailsManager
*/
@Configuration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean({ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class })
public class UserDetailsServiceAutoConfiguration {
private static final String NOOP_PASSWORD_PREFIX = "{noop}";
private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean(
type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
// 从SecurityProperties中获取创建的用户对象
SecurityProperties.User user = properties.getUser();
// 从用户对象中获取角色
List<String> roles = user.getRoles();
// 创建InMemoryUserDetailsManager的spring bean
return new InMemoryUserDetailsManager(
User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles)).build());
}
// 获取或者是推演一个用户的密码
private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}
}
如下是我本地debug运行时的结果:
从getOrDeducePassoword方法可以看到当没有在配置文件中指定密码使用生成的密码时会打印UUID生成的密码,如下是我本地的输出:
2.4:定义一个Controller
@RestController
@RequestMapping("/admin")
public class AdminController {
@GetMapping("/demo")
public String demo() {
return "示例返回";
}
}
启动项目访问http://localhost:8049/admin/demo
,会被springsecurity拦截到登录界面,如下:
执行拦截工作的是类org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
,这是servlet过滤器javax.servlet.Filter
的一个子类,如下是跳转到登录页面相关源码:
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
boolean loginError = isErrorPage(request);
boolean logoutSuccess = isLogoutSuccess(request);
if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
// 生成登录的HTML
String loginPageHtml = generateLoginPageHtml(request, loginError,
logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
return;
}
chain.doFilter(request, response);
}
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError,
boolean logoutSuccess) {
String errorMsg = "Invalid credentials";
if (loginError) {
HttpSession session = request.getSession(false);
if (session != null) {
AuthenticationException ex = (AuthenticationException) session
.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
errorMsg = ex != null ? ex.getMessage() : "Invalid credentials";
}
}
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ " <head>\n"
+ " <meta charset=\"utf-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n"
+ " <meta name=\"description\" content=\"\">\n"
+ " <meta name=\"author\" content=\"\">\n"
+ " <title>Please sign in</title>\n"
+ " <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n"
+ " <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n"
+ " </head>\n"
+ " <body>\n"
+ " <div class=\"container\">\n");
String contextPath = request.getContextPath();
if (this.formLoginEnabled) {
sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n"
+ " <h2 class=\"form-signin-heading\">Please sign in</h2>\n"
+ createError(loginError, errorMsg)
+ createLogoutSuccess(logoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Username</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
+ " </p>\n"
+ " <p>\n"
+ " <label for=\"password\" class=\"sr-only\">Password</label>\n"
+ " <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n"
+ " </p>\n"
+ createRememberMe(this.rememberMeParameter)
+ renderHiddenInputs(request)
+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
+ " </form>\n");
}
if (openIdEnabled) {
sb.append(" <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.openIDauthenticationUrl + "\">\n"
+ " <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n"
+ createError(loginError, errorMsg)
+ createLogoutSuccess(logoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Identity</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"" + this.openIDusernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
+ " </p>\n"
+ createRememberMe(this.openIDrememberMeParameter)
+ renderHiddenInputs(request)
+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
+ " </form>\n");
}
if (oauth2LoginEnabled) {
sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("<table class=\"table table-striped\">\n");
for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName.entrySet()) {
sb.append(" <tr><td>");
String url = clientAuthenticationUrlToClientName.getKey();
sb.append("<a href=\"").append(contextPath).append(url).append("\">");
String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
sb.append(clientName);
sb.append("</a>");
sb.append("</td></tr>\n");
}
sb.append("</table>\n");
}
sb.append("</div>\n");
sb.append("</body></html>");
return sb.toString();
}
}
在登录界面录入配置的用户名密码就可以登录了,登录完成后会自动重定向到最初访问的页面,如下:
3:进阶使用
本部分我们尝试在前一部分基础上进行一些增强定制工作。
3.1:实例1
3.1.1:SecurityConfig
对springsecurity进行相关配置,通过实现类WebSecurityConfigurerAdapter
进行定制,定义如下:
// 使其成为java config类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
如下方法是对用户和角色等信息进行设置:
// 配置认证和授权相关信息
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用内存中的InMemoryUserDetailsManager
auth.inMemoryAuthentication()
// 指定密码编码器为无擦操作密码编码器,即不使用密码编码器,但是如果不指定的话会报错,所以这里还是需要指定
.passwordEncoder(NoOpPasswordEncoder.getInstance())
// 配置admin账号以及其角色
.withUser("admin").password("admin").roles("ADMIN")
// 配置普通用户账号以及其角色
.and().withUser("normal").password("normal").roles("NORMAL");
}
inMemoryAuthentication()
是设置使用基于内存中的InMemoryUserDetailsManager类,另外还有基于JDBC的JdbcUserDetailsManager
,二者都是顶层接口org.springframework.security.core.userdetails.UserDetailsService
,如下图:
一般我们自定义接口UserDetailsService的子类来完成相关的设置,这样更加灵活的进行用户信息的设置和读取。
指定密码编码器为无擦操作密码编码器,即不使用密码编码器,但是如果不指定的话会报错,所以这里还是需要指定,生产上推荐使用BCryptPasswordEncoder
,密码编码器对应的顶层接口是org.springframework.security.crypto.password.PasswordEncoder
,源码如下:
// 用来编码密码的服务接口类,比较优秀的实现类是BCryptPasswordEncoder
public interface PasswordEncoder {
// 对原始密码进行编码
String encode(CharSequence rawPassword);
// 验证传入的原始密码是否和存储的编码后的密码一致,一致则返回true,否则返回false
// 注意:编码后的密码为了安全性永远不能被解码,即不能编码后的密码解码和原始密码比较是否一致
boolean matches(CharSequence rawPassword, String encodedPassword);
// 判断当前已经编码的密码是否不足够安全,是的话则返回true,否则返回false
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
接下来对http访问相关方法重写,设置url访问等信息,如下:
// 配置访问控制相关信息
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置请求地址的权限
http.authorizeRequests()
// 地址/test/echo允许所有用户访问
.antMatchers("/test/echo").permitAll()
// 只有拥有ADMIN角色的用户才可以访问
.antMatchers("/test/admin").hasRole("ADMIN")
// 只有拥有NORMAL角色的用户才可以访问
.antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')")
// 访问任何请求的用户都需要认证,即都是登录过的用户
.anyRequest().authenticated()
// 设置使用默认的表单登录,并且所有用户可访问,可通过方法
.and()
.formLogin()
.permitAll()
// 设置退出登录页面,并设置所有人可访问
.and()
.logout()
.permitAll();
}
antMatchers("/test/echo").permitAll()
设置访问地址/test/echo
可以随意访问,不受任何限制。antMatchers("/test/admin").hasRole("ADMIN")
设置访问地址/test/admin
必须拥有ADMIN角色才能访问。antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')")
设置必须拥有NORMAL
角色才能访问。anyRequest().authenticated()
设置没有显示设置的访问地址都必须认证才能访问,即必须登录。最后的and().formLogin().permitAll()
和and().logout().permitAll()
是设置任何人都可以访问登录和退出登录。
3.1.2:TestController
源码如下:
@RestController
@RequestMapping("/test")
public class TestController {
// 通过".antMatchers("/test/echo").permitAll(),"单独配置,所以该接口所有用户可访问
@GetMapping("/echo")
public String demo() {
return "示例返回";
}
// 该接口并未单独设置,所有通过配置".anyRequest().authenticated()"访问该页面需要登录
@GetMapping("/home")
public String home() {
return "我是首页";
}
// 该接口单独设置,设置为".antMatchers("/test/admin").hasRole("ADMIN")",所以登录用户必须有ADMIN角色才可以访问
@GetMapping("/admin")
public String admin() {
return "我是管理员";
}
// 该接口单独设置,设置为".antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')")",所以必须有NORMAL角色才可以访问
@GetMapping("/normal")
public String normal() {
return "我是普通用户";
}
}
如果是没有登录时,访问需要登录的接口则会跳转到登录页面,登录后访问没有权限访问的页面则会403
,如下是使用normal用户登录后访问http://localhost:8049/test/admin
:
如果是我们不重写protected void configure(HttpSecurity http)
则默认的行为是访问所有的页面都必须认证,即需要登录,并使用默认的登录和退出登录页面,源码如下:
protected void configure(HttpSecurity http) throws Exception {
this.logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
((HttpSecurity)((HttpSecurity)((AuthorizedUrl)http.authorizeRequests().anyRequest()).authenticated().and()).formLogin().and()).httpBasic();
}
除了重写该方法进行自定义的访问限制之外,还可以通过注解的方式来实现,首先在配置类SecurityConfig添加注解@EnableGlobalMethodSecurity
开启就有注解的配置,如下:
// 使其成为java config类
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {}
然后在TestController
中添加相关的授权注解,修改如下:
@RestController
@RequestMapping("/test")
public class TestController {
// 相当于代码设置".antMatchers("/test/echo").permitAll(),",所以该接口所有用户可访问
@PermitAll
@GetMapping("/echo")
public String demo() {
return "示例返回";
}
// 该接口并未单独设置,相当于使用默认的protected void configure(HttpSecurity http)方法的默认实现,即走所有请求都需要登录
@GetMapping("/home")
public String home() {
return "我是首页";
}
// 相当于代码设置".antMatchers("/test/admin").hasRole("ADMIN")",登录用户必须有ADMIN角色才可以访问
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/admin")
public String admin() {
return "我是管理员";
}
// 相当于代码设置".antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')")",所以必须有NORMAL角色才可以访问
@PreAuthorize("hasRole('ROLE_NORMAL')")
@GetMapping("/normal")
public String normal() {
return "我是普通用户";
}
}
效果是完全相同的,大家可以自己试下。
4:自定义登录页面实例
在前面用到的是springsecurity提供的默认页面,本实例,我们从0开始看下如何自定义登录页面来完成认证和授权。
4.1:准备工作
- 引入springmvc的起步依赖
<!-- 通过依赖管理来统一springboot相关版本和简化pom声明 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.1.14.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- springMVC起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
- 定义控制器
@Controller
public class HelloController {
@RequestMapping("/")
public String index() {
return "index";
}
@RequestMapping("/hello")
public String hello() {
return "hello";
}
}
- 定义相关页面
resources/index.html:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security入门</title>
</head>
<body>
<h1>欢迎使用Spring Security!</h1>
<p>点击 <a th:href="@{/hello}">这里</a> 打个招呼吧</p>
</body>
</html>
resources/hello.html:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="注销"/>
</form>
</body>
</html>
此时项目结构如下:
- 访问测试
点击这里
:
可以看到此时的访问是不受任何控制的,随意访问。
4.2:整合springsecurity
- 引入springsecurity起步依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
访问http://localhost:8080/
的话就会跳转到登录页面,如下:
因为默认的安全配置类org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter#configure(HttpSecurity)
,设置了任何请求都需要认证,即登录,源码如下:
// org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter#configure(HttpSecurity)
protected void configure(HttpSecurity http) throws Exception {
logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
- 添加自定义的配置类
@Configuration
public class CustomLoginSecurityConfig extends WebSecurityConfigurerAdapter {
// 配置http的访问控制
@Override
protected void configure(HttpSecurity http) throws Exception {
// 允许访问/, /home
http.authorizeRequests().antMatchers("/", "/home")
// 所有其他请求都需要认证,即登录
.permitAll().anyRequest().authenticated()
.and()
// 配置/login为登录地址,并允许所有人访问该地址
.formLogin().loginPage("/login").permitAll()
.and()
// 使用默认的退出登录页面,并允许所有人访问
.logout().permitAll();
}
// 配置认证授权使用的用户和角色
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password("user")
.roles("USER");
}
}
- 添加登录页面
templates/login.html:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div th:if="${param.error}">
用户名或密码错
</div>
<div th:if="${param.logout}">
您已注销成功
</div>
<form th:action="@{/login}" method="post">
<div><label> 用户名 : <input type="text" name="username"/> </label></div>
<div><label> 密 码 : <input type="password" name="password"/> </label></div>
<div><input type="submit" value="登录"/></div>
</form>
</body>
</html>
此时项目结构如下:
- 访问测试
http://localhost:8080/
可以正常访问:
点击这里
因为需要认证所以跳转到配置的登录页面:
输入账号密码user/user
:
点击注销
即可退出登录,并回到登录页面: