Spring Security 入门 (二)
我们在篇(一)中已经谈到了默认的登录页面以及默认的登录账号和密码。
在这一篇中我们将自己定义登录页面及账号密码。
我们先从简单的开始吧:设置自定义的账号和密码(并非从数据库读取),虽然意义不大。
上一篇中,我们仅仅重写了 configure(HttpSecurity http) 方法,该方法是用于完成用户授权的。
为了完成自定义的认证,我们需要重写 configure(AuthenticationManagerBuilder auth) 方法。
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // TODO Auto-generated method stub auth.inMemoryAuthentication().withUser("Hello").password("{noop}World").roles("USER"); } @Override protected void configure(HttpSecurity http) throws Exception { // TODO Auto-generated method stub http.authorizeRequests() .antMatchers("/login").permitAll() .antMatchers("/user").hasRole("USER") .anyRequest().authenticated() .and() .formLogin().defaultSuccessUrl("/hello"); } }
这个就是新的 WebSecurityConfig 类,控制器里面的方法我就不写了,仿照(一)很容易写出来,运行结果你们自己测试吧。
configure(AuthenticationManagerBuilder auth) 方法中,AuthenticationManagerBuilder 的 inMemoryAuthentication() 方法
可以添加用户,并给用户指定权限,它还有其他的方法,我们以后用到再讲。
在 Password 的地方我们需要注意了:
Spring 5.0 之后为了更加安全,修改了密码存储格式,密码存储格式为{id}encodedPassword。
id 是一个标识符,用于查找是哪个 PasswordEncoder,也就是密码加密的格式所对应的 PasswordEncoder。
encodedPassword 是指原始密码经过加密之后的密码。id 必须在密码的前面,id前后必须加 {}。
如果 id 找不到,id 则会为空,会抛出异常:There is no PasswordEncoder mapped for id "null"。
好啦,重点来啦,我们现在开始设置自定义登录页面,并从数据库读取账号密码。
一般来讲,我们先讲认证原理及流程比较好,不过这个地方我也说不太清楚。那我们还是从例子说起吧。
我用的是 MyBaits 框架操作 Mysql 数据库。为了支持它们,我们需要在原来的 pom.xml 中添加依赖。
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>
好啦,现在我们首先定义一个用户对象以及一个角色对象。
package security.pojo; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Set; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; public class SimpleUser implements UserDetails { private static final long serialVersionUID = 1L; private String username; private String password; private String name; private String telephone; private String email; private String headImg; private boolean status = true; private Set<Role> roles; public SimpleUser() { super(); } public SimpleUser(String username, String password, String telephone) { super(); this.username = username; this.password = password; this.telephone = telephone; } public Set<Role> getRoles() { return roles; } public void setRoles(Set<Role> roles) { this.roles = roles; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { // TODO Auto-generated method stub if(!roles.isEmpty()) { List<GrantedAuthority> authorities = new ArrayList<>(); for (Role role : roles) { authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName())); } return authorities; } return null; } @Override public String getPassword() { // TODO Auto-generated method stub return password; } @Override public String getUsername() { // TODO Auto-generated method stub return username; } @Override public boolean isAccountNonExpired() { // TODO Auto-generated method stub return true; } @Override public boolean isAccountNonLocked() { // TODO Auto-generated method stub return true; } @Override public boolean isCredentialsNonExpired() { // TODO Auto-generated method stub return true; } @Override public boolean isEnabled() { // TODO Auto-generated method stub return status; } public String getTelephone() { return telephone; } public void setTelephone(String telephone) { this.telephone = telephone; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public boolean getStatus() { return status; } public void setStatus(boolean status) { this.status = status; } public void setUsername(String username) { this.username = username; } public void setPassword(String password) { this.password = password; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getHeadImg() { return headImg; } public void setHeadImg(String headImg) { this.headImg = headImg; } }
package security.pojo; public class Role { private String username; private String name; public Role() { super(); } public Role(String username, String name) { super(); this.username = username; this.name = name; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
然后,为了根据用户名找到用户,我们定义 Mapper:
package security.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Insert; import security.pojo.SimpleUser; @Mapper public interface SimpleUserMapper { @Select("select * from users where username = #{username}") public SimpleUser findUserByUsername(String username); @Insert("insert into users(username,password,telephone) values(#{username},#{password},#{telephone})") public int addSimpleUser(SimpleUser user); }
package security.mapper; import java.util.Set; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Select; import security.pojo.Role; @Mapper public interface RoleMapper { @Select("select * from roles where username = #{username}") public Set<Role> findRolesByUsername(String username); }
而这样的一个 Mapper 是不会加载到 Bean 中去的,我们需要对这个类进行扫描:
package security; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("security.mapper") public class SecurityApplication { public static void main(String[] args) { SpringApplication.run(SecurityApplication.class, args); } }
好啦,这个 Mapper 已经成为一个 Bean 了,下面的将是重点:来自 《Spring Boot 2 企业应用实战》
1、UserDetails
UserDetails 是 Spring Security 的一个核心接口。其中定义了一些可以获取用户名、密码、权限等与认证相关信息的方法。
Spring Security 内部使用的 UserDetails 实现类大都是内置的 User 类,要使用 UserDetails,也可以直接使用该类。
在 Spring Security 内部,很多需要使用用户信息的时候,基本上都是使用 UserDetails,比如在登录认证的时候。
UserDetails 是通过 UserDetailsService 的 loadUserByUsername() 方法进行加载的。
我们也需要实现自己的 UserDetailsService 来加载自定义的 UserDetails 信息。
2、UserDetailsService
Authentication.getPrincipal() 的返回类型是 Object,但很多情况下返回的其实是一个 UserDetails 的实例。
登录认证的时候 Spring Security 会通过 UserDetailsService 的 loadByUsername() 方法获取相对应的 UserDetails
进行认证,认证通过后会将改 UserDetails 赋给认证通过的 Authentication 的 principal,
然后再把该 Authentication 存入 SecurityContext。之后如果需要使用用户信息,
可以通过 SecurityContextHolder 获取存放在 SecurityContext 中的 Authentication 的 principal。
3、Authentication
Authentication 用来表示用户认证信息,在用户登录认证之前,
Spring Security 会将相关信息封装为一个 Authentication
具体实现类的对象,在登录认证成功之后又会生成一个信息更全面、包含用户权限等信息的 Authentication 对象,
然后把它保存在 SpringContextHolder 所持有的 SecurityContext 中,供后续的程序进行调用,如访问权限的鉴定等。
4、SecurityContextHolder
SecurityContextHolder 是用来保存 SecurityContext 的。SecurityContext 中含有当前所访问系统的用户的详细信息。
默认情况下,SecurityContextHolder 将使用 ThreadLocal 来保存 SecurityContext。
这也就意味着在处于同一线程的方法中,可以从 ThreadLocal 获取到当前 SecurityContext。
好啦,这个地方就到这儿啦,没弄懂也不要紧,我们能看懂例子就行了:
package security.service; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import security.mapper.RoleMapper; import security.mapper.SimpleUserMapper; import security.pojo.Role; import security.pojo.SimpleUser; @Service public class SimpleUserService implements UserDetailsService { @Autowired private SimpleUserMapper userMapper; @Autowired private RoleMapper roleMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // TODO Auto-generated method stub SimpleUser user = userMapper.findUserByUsername(username); Set<Role> roles = roleMapper.findRolesByUsername(username); if(user == null) { throw new UsernameNotFoundException("Username or Password is not correct"); } user.setRoles(roles); return new User(user.getUsername(),user.getPassword(),user.getAuthorities()); } public int addSimpleUser(SimpleUser user) { user.setPassword(new BCryptPasswordEncoder().encode(user.getPassword())); return userMapper.addSimpleUser(user); } }
在这个类中,我们实现了 UserDetailsService 接口,然后重写了 loadUserByUsername(String username) 方法。
之后自动注入了一个根据用户名查找用户的 Mapper,再将查找的用户对象复制给 user。
当存在这个用户的时候,我们获取它的权限添加到权限列表中,然后把这个列表以及用户名,密码存入到 UserDetails 对象中。
因为一个用户的权限可能不止一个,所以是一个权限列表。
最后我们到了配置环节了:
package security.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import security.service.SimpleUserService; // 重写DaoAuthenticationProvider,authentication 携带username,password信息 @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private SimpleUserService userService; @Autowired private AuthenticationProvider authenticationProvider; private MessageSource messageSource; protected void configure(AuthenticationManagerBuilder auth) throws Exception { // TODO Auto-generated method stub auth.authenticationProvider(authenticationProvider); } @Override protected void configure(HttpSecurity http) throws Exception { // TODO Auto-generated method stub http .authorizeRequests() .anyRequest().permitAll() .and() .formLogin().loginPage("/signin") .usernameParameter("username") .passwordParameter("password") .loginProcessingUrl("/signin") .and() .csrf().disable(); } @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new CustomAuthenticationProvider(); provider.setMessageSource(messageSource); provider.setUserDetailsService(userService); provider.setPasswordEncoder(new BCryptPasswordEncoder()); return provider; } }
package security.config; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.util.Assert; public class CustomAuthenticationProvider extends DaoAuthenticationProvider { @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { // TODO Auto-generated method stub String presentedPassword = authentication.getCredentials().toString(); if (!getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) { logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage( "UNameOrPwdIsError","Username or Password is not correct")); } } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // TODO Auto-generated method stub Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); if("".equals(authentication.getPrincipal())) { throw new BadCredentialsException(messages.getMessage( "UsernameIsNull","Username cannot be empty")); } if("".equals(authentication.getCredentials())) { throw new BadCredentialsException(messages.getMessage( "PasswordIsNull","Password cannot be empty")); } String username = (String) authentication.getPrincipal(); boolean cacheWasUsed = true; UserDetails user = this.getUserCache().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( "UNameOrPwdIsError","Username or Password is not correct")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { getPreAuthenticationChecks().check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); getPreAuthenticationChecks().check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } getPostAuthenticationChecks().check(user); if (!cacheWasUsed) { this.getUserCache().putUserInCache(user); } Object principalToReturn = user; if (isForcePrincipalAsString()) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); } }
package security.config; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.context.support.ResourceBundleMessageSource; public class MessageSource extends ResourceBundleMessageSource { public MessageSource() { setBasename("messages"); } public static MessageSourceAccessor getAccessor() { return new MessageSourceAccessor(new MessageSource()); } }
在第一个类中我们重写了两个 configure() 方法。其中一个我们之前谈过,不过并没有讲全,现在补充一下:
在 formLogin() 下还有 .usernameParameter() 和 .passwordParameter() 以及 .loginProcessingUrl("/login") 这三个函数。
前两个函数是用于指定登录页面用户名及密码的标识的,后面的一个是用于表单请求的 action 参数。
defaultSuccessUrl 是指定登录成功显示的页面,failureUrl 是指定登录失败显示的页面。
还有其他的一些我们以后用到再讲。
另一个 configure() 方法是用于认证的。我们这里仅仅只写了一行代码。
我们把之前的 @Service 的那个类注入到了 userService 中,再把 @Bean 的那个 Bean 注入到了 authenticationProvider 中。
在这个 Bean 里面有个 DaoAuthenticationProvider 类:
Spring Security 默认会使用 DaoAuthenticationProvider 实现 AuthenticationProvider 接口,专门进行用户认证处理。
DaoAuthenticationProvider 在进行认证处理的时候需要一个 UserDetailsService 来获取用户的信息 UserDetails,
其中包括用户名,密码和所拥有的权限等。
看到这些代码,可以知道我们写的代码都有联系了。我们还差一个控制器的代码:
package security.controller; import java.util.Random; import security.pojo.SimpleUser; import security.service.SimpleUserService; import com.zhenzi.sms.ZhenziSmsClient; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller public class SecurityController { @Autowired private SimpleUserService userService; @GetMapping("/signin") public String signIn() { return "signin"; } @GetMapping("/signup") public String signUp() { return "signup"; } @PostMapping("/sign_up") public String regist(@RequestParam(value="verifycode") String code,HttpServletRequest request, SimpleUser user) { String verifycode = (String) request.getSession().getAttribute("verifyCode"); if(!code.equals(verifycode)){ return "failure"; } userService.addSimpleUser(user); return "signin"; } @PostMapping("/sendsms")// 若不要 response 参数,则会发出 /sendsms 请求。 public void sendsms(HttpServletRequest request, HttpServletResponse response, String telephone) { try { String verifyCode = String.valueOf(new Random().nextInt(899999) + 100000); ZhenziSmsClient client = new ZhenziSmsClient("******", "******", "******"); client.send(telephone, "您的验证码为 " + verifyCode + ",有效期为 3 分钟,如非本人操作,可不予理会!"); request.getSession().setAttribute("verifyCode", verifyCode); } catch (Exception e) { e.printStackTrace(); } } }
好啦,到此 java 代码就结束了。
前面我们设置了 usernameParameter("username"),passwordParameter("password"),
另外由于默认的登录页面表单请求的 action="/login",用户名参数和密码分别为 "username","password"。
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; private String usernameParameter = "username"; private String passwordParameter = "password"; private boolean postOnly = true; public UsernamePasswordAuthenticationFilter() { super(new AntPathRequestMatcher("/login", "POST")); } // ... ... }
如果用 thymeleaf 模板的话,这三个参数就分别用 th:action="{/login}" ,th:name="username",th:name="password"。
若是我们想自定义的话,比如登录页面为 signin.html,登录请求的 action 为 "/signin",
用户名参数为 uname,密码参数为 pwd。
@Override protected void configure(HttpSecurity http) throws Exception { // TODO Auto-generated method stub http.authorizeRequests() .antMatchers("/css/**","/images/*","/js/**","/login").permitAll() .antMatchers("/index").hasRole("USER") .anyRequest().authenticated() .and() .formLogin().loginPage("/login") .usernameParameter("uname") .passwordParameter("pwd") .loginProcessingUrl("/sign") .defaultSuccessUrl("/success") .failureUrl("/failure"); }
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Insert title here</title> </head> <body> <form th:action="@{/signin}" method="post"> <input th:name="uname" type="text"> <input th:name="pwd" type="password"> <input type="submit" value="login"> </form> </body> </html>
熬,对啦,连接数据库的地方需要写在 application.properties 文件里:
注意了,那个 url 数据库(security)后面一定要写上 ?serverTimezone=UTC&characterEncoding=utf-8 这样的,不然会出错的。
至此,入门项目就结束了,所有的源码都在上面啦,觉得可以的话点个赞啦!
链接:https://pan.baidu.com/s/13fc6P9NV49aRRBctr3MjNQ
提取码:4qgu