Spring Security
介绍
Spring Security是Spring提供的一个安全框架,提供用户认证和用户授权功能,最主要的是它提供了简单的使用方式,同时又有很高的灵活性,简单,灵活,强大。
入门
配置类详解
@Override
protected void configure(HttpSecurity http) throws Exception { // 配置授权等
http.rememberMe() // 开启 rememberMe 功能
.rememberMeParameter("remember") // 指定传递的参数名(需与前端一致)
.rememberMeCookieName("remember") // 指定返回 cookie 中参数的名字
.tokenValiditySeconds(2*24*60*60) // 令牌有效时间
// 前端可直接使用 checkbox name="remember-me" + submit 按钮
// 如果是使用 ajax 需自行判断并添加 "remember-me" 参数
.and().csrf().disable() // 禁用跨站请求伪造防御
// 会拦截所有 POST 请求,检查是否携带 token,自带登录无影响
//.and().authorizeRequests() // 动态鉴权
// .antMatchers("/login").permitAll()
// .antMatchers("/index").authenticated()
// .anyRequest().access(
// "@authorizeServiceImpl.hasPermission(request, authentication)")
.authorizeRequests() // 授权配置
.antMatchers("/login", "/login.html") // 包含页面,后跟授权
.permitAll() // 授权:所有人可访问
.antMatchers("/login", "/login.html")
.hasAnyAuthority("ROLE_admin", "login") // 权限形式授权
// .hasAnyRole("admin", "user") // 角色形式授权(二选一即可)
// .hasRole("admin").hasAuthority // 单角色/权限形式
// hasAuthority("ROLE_admin") 等价于 hasRole("admin")
// 若 hasAuthority("xxx") 则表示指定权限名为 xxx
// 下方授权可以访问上方授权页面,反之不能
.anyRequest() // 任意请求(不包括上面的)
.authenticated() // 登录可访问
// .and() // 模式配置
// .formLogin() // 表单模式
// .loginPage("/login.html") // 登录页面
// .loginProcessingUrl("/login") // 拦截 URL
// .defaultSuccessUrl("/index") // 登录跳转 URL
// .failureUrl("/login"); // 失败跳转 URL
.and().httpBasic() // HttpBasic 模式
.and().sessionManagement() // seesion 管理
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // session 创建策略
.invalidSessionUrl("/login") // 超时跳转 URL
.sessionFixation().none() // 配置 session 保护
.maximumSessions(1) // 最大 session 连接数
// true:不可再次登录;false:之前登录的 session 会下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new SessionExpiredStrategy()); // 超时配置
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 用户权限配置
auth.inMemoryAuthentication() // 从内存中读取角色权限
.withUser("user") // 配置用户名
.password(passwordEncoder().encode("123456")) // 配置密码
.roles("user") // 配置角色
// .authorities("xxx") // 指定权限,角色和权限都可以配置多个
.and()
.withUser("admin")
.password(passwordEncoder().encode("123456"))
.roles("admin")
.and()
.passwordEncoder(passwordEncoder()); // 配置加密方式
// 从数据库动态加载配置,需要配置好相关文件
// auth.userDetailsService(userService)
// .passwordEncoder(passwordEncoder());
}
@Override
public void configure(WebSecurity web) throws Exception { // 静态资源配置(不拦截)
web.ignoring()
.antMatchers("/css/*", "/fonts/*", "/js/*", "/img/*");
}
@Bean
public PasswordEncoder passwordEncoder() { // 密码加密方式
return new BCryptPasswordEncoder();
}
登录
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.2.6.RELEASE</version>
</dependency>
编写测试接口
@RestController
public class UserController {
// @GetMapping("login")
// public String hello() {
// return "login";
// }
@GetMapping("index")
public String index() {
return "index";
}
@GetMapping("user")
public String user() {
return "user";
}
@GetMapping("admin")
public String admin() {
return "admin";
}
}
基于 HttpBasic
模式的登录认证
// SecurityConfig.java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() // 授权配置
.anyRequest() // 任意请求
.authenticated() // 登录可访问
.and()
.httpBasic(); // 使用 HttpBasic 模式
}
}
访问 http://localhost:8080/hello 查看效果
自定义用户名密码
spring:
security:
user:
name: admin # 自定义用户名
password: 123456 # 自定义密码
HttpBasic
模式会将密码进行进行base64
后放在header
中传输给服务器,安全性差
基于 FormLogin
表单模式的登录认证
三要素
- 登录认证逻辑
- 资源访问控制
- 用户角色权限
编写配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/user").hasAnyRole("user")
.antMatchers("/admin").hasAnyAuthority("ROLE_admin")
.anyRequest().authenticated()
.and()
.formLogin()
// tips: 默认 processingUrl = loginPage = /login
// 若只配置 page, processingUrl 也会默认配置为 page而非 /login
// .loginPage("/login.html") // 登录页面
// .loginProcessingUrl("/login") // 拦截 URL
.defaultSuccessUrl("/index") // 登录跳转 URL
.failureUrl("/login.html"); // 失败跳转 URL
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication() // 从内存中读取角色权限
.withUser("user")
.password(passwordEncoder().encode("123"))
.roles("user")
.and()
.withUser("admin")
.password(passwordEncoder().encode("123"))
.roles("admin")
.and()
.passwordEncoder(passwordEncoder()); // 加密方式
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/css/*", "/fonts/*", "/js/*", "/img/*");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
登录成功与失败
统一返回结果
// Result.java
@Data
public class Result {
public interface ResultCode {
Integer SUCCESS = 20000;
Integer ERROR = 20001;
Integer TIMEOUT = 20002;
}
private Boolean success;
private Integer code;
private String message;
private Map<String, Object> data = new HashMap<String, Object>();
private Result(){} // 私有化,让此类只能使用 success(), error()(限定状态)
public static Result success(){
Result r = new Result();
r.setSuccess(true);
r.setCode(ResultCode.SUCCESS);
r.setMessage("成功");
return r;
}
public static Result error(){
Result r = new Result();
r.setSuccess(false);
r.setCode(ResultCode.ERROR);
r.setMessage("失败");
return r;
}
// return this 可以让方法返回本身,进行链式编程
public Result success(Boolean success){
this.setSuccess(success);
return this;
}
public Result message(String message){
this.setMessage(message);
return this;
}
public Result code(Integer code){
this.setCode(code);
return this;
}
public Result data(String key, Object value){
this.data.put(key, value);
return this;
}
public Result data(Map<String, Object> map){
this.setData(map);
return this;
}
}
SuccessHandler
@Component
public class SuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { // 登录成功自定义处理
// 对象与 json 转换类
private static ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
response.setContentType("application/json;charset=UTF-8"); // 定义返回 json
response.getWriter().write(objectMapper.writeValueAsString(Result.success()));
}
}
FailHandler
@Component
public class FailHandler extends SimpleUrlAuthenticationFailureHandler {
private static ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(Result.error().message("用户名或密码错误")));
}
}
配置方法
http.formLogin()
.successHandler(successHandler) // 和 .defaultSuccessUrl() 二选一
.failureHandler(failHandler); // 同理
登出
退出的默认行为
- 使当前
session
失效:回收权限 - 删除
RememberMe
信息(包括数据库的 Token) - 清除当前的 SecurityContext 信息
- 重定向到登录页面(
loginPage
配置指定页面)
配置文件
http.logout() // 以下可选
.logoutUrl("/signOut") // 跳转 URL(与logoutSuccessHandler 二选一)
.logoutSuccessUrl("/logoutSuccess") // 自定义退出页面(需要配置所有人可访问的页面)
.deleteCookies("JSESSIONID") // 删除 cookie 中的 session id(也可删除其他)
// .logoutSuccessHandler(logoutHandler) // 自定义退出逻辑
// 需要自定义一个 Handler 实现 logoutSuccessHandler 接口
Session 管理
常用配置
server:
servlet:
session:
timeout: 300s # 默认 30min, 如果低于 1min, 会设置成 1min
cookie:
http-only: true # 脚本无法访问 cookie
secure: true # 仅允许 https 协议发送 cookie
创建策略
always
:如果当前请求没有session
,则创建never
:有session
则使用,没有也不创建ifRequired
:在需要时才创建session
(默认)stateless
:不会创建和使用任何session
,适用于无状态应用
Session
保护机制
migrateSession
:每次登录创建新的session
,复制属性并使旧session
失效(默认)changeSessionId
:每次登录只更换session
的ID
newSession
:每次登录创建一个新session
none
:直接使用原来的session
超时配置类
// 编写超时回调方法
public class SessionExpiredStrategy implements SessionInformationExpiredStrategy {
// 对象与 json 转换类
private static ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
// 超时时候回调方法
event.getResponse().setContentType("application/json;charset=UTF-8");
event.getResponse().getWriter().write(
objectMapper.writeValueAsString(
Result.error().code(
Result.ResultCode.TIMEOUT).message("你的账号在别处登录,被迫下线")));
}
}
配置类
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // session 创建策略
.invalidSessionUrl("/login") // 超时跳转 URL
.sessionFixation().none() // 配置 session 保护
.maximumSessions(1) // 最大 session 连接数
// true:不可再次登录;false:之前登录的 session 会下线
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(new SessionExpiredStrategy()) // 超时配置
RBAC
权限管理模型
- 用户:系统接口及访问的操作者
- 权限:能够访问接口或操作的的授权资格
- 角色:具有一定数量操作权限的集合
// TODO
权限配置
动态加载权限信息
- 配置用户信息
- 编写 Dao层
- 实现
loadUserByUsername
方法
这里的代码都是在使用 Mybatis Plus 的代码生成器上添加部分代码
编写用户信息
// User.java
// 主要需要实现 UserDetails 接口,本身 User 会存放用户信息
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("ams_user")
@ApiModel(value="User对象", description="")
public class User implements UserDetails,Serializable {
private static final long serialVersionUID=1L;
@ApiModelProperty(value = "用户名 ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "用户名")
private String username;
@ApiModelProperty(value = "密码")
private String password;
private Integer orgId;
private Integer enabled;
private Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
编写 UserMapper
@Repository
public interface UserMapper extends BaseMapper<User> {
/**
* 根据用户名返回用户信息
* @param username 用户名
* @return UserDetailsImpl
*/
@Select("SELECT username, password, enabled\n" +
"FROM ams_user\n" +
"WHERE username = #{username}")
User findUserByUsername(@Param("username") String username);
/**
* 根据用户名返回角色列表
* @param username 用户名
* @return List<String>
*/
@Select("SELECT code\n" +
"FROM ams_role r\n" +
"LEFT JOIN ams_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN ams_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{username}")
List<String> findRoleByUsername(@Param("username") String username);
/**
* 根据角色列表查询用户权限
* @param roleCodes 角色列表
* @return List<String>
*/
@Select({
"<script>",
"SELECT url ",
"FROM ams_menu m ",
"LEFT JOIN ams_role_menu rm ON m.id = rm.menu_id ",
"LEFT JOIN ams_role r ON r.id = rm.role_id ",
"WHERE r.code IN ",
"<foreach collection = 'roleCodes' item = 'roleCode' open = '(' separator = ',' close = ')' >",
"#{roleCode}",
"</foreach>",
"</script>"
})
List<String> findAuthorityByRoleCodes(@Param("roleCodes")List<String> roleCodes);
}
实现 loadUserByUsername
方法
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService, UserDetailsService {
UserMapper mapper;
@Autowired
public UserServiceImpl(UserMapper mapper) {
this.mapper = mapper;
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
// 加载基础用户信息
User user = mapper.findUserByUsername(username);
// 加载用户角色表
List<String> roleCodes = mapper.findRoleByUsername(username);
// 通过用户角色表加载权限
List<String> authoritys = mapper.findAuthorityByRoleCodes(roleCodes);
// 角色也是特殊的权限,加上 ROLE_ 前缀后加入权限列表
roleCodes = roleCodes.stream().map(rc -> "ROLE_" + rc).collect(Collectors.toList());
authoritys.addAll(roleCodes);
// 权限列表加入到 UserDetails
user.setAuthorities(
// commaSeparatedStringToAuthorityList 接受逗号分隔的字符串
AuthorityUtils.commaSeparatedStringToAuthorityList(
String.join(",", authoritys)));
return user;
}
}
动态鉴权
编写 Dao 层
@Repository
public interface AuthorizeMapper {
/**
* 查询权限 URL
* @param username 用户名
* @return Lise<String>
*/
@Select("SELECT url\n" +
"FROM ams_menu m\n" +
"LEFT JOIN ams_role_menu rm ON m.id = rm.menu_id\n" +
"LEFT JOIN ams_role r ON r.id = rm.role_id\n" +
"LEFT JOIN ams_user_role ur ON r.id = ur.role_id\n" +
"LEFT JOIN ams_user u ON u.id = ur.user_id\n" +
"WHERE u.username = #{username}")
List<String> findUrlByUsername(@Param("username") String username);
}
编写 Service 层
public interface AuthorizeService {
/**
* 判断用户是否具有 request 请求中的权限
* @param request Request 请求
* @param authentication 权限认证接口
* @return boolean
*/
boolean hasPermission(HttpServletRequest request, Authentication authentication);
}
@Service
public class AuthorizeServiceImpl implements AuthorizeService {
private AuthorizeMapper mapper;
// URL 匹配工具类
private AntPathMatcher matcher = new AntPathMatcher();
@Autowired
public AuthorizeServiceImpl(AuthorizeMapper mapper) {
this.mapper = mapper;
}
@Override
public boolean hasPermission(HttpServletRequest request,
Authentication authentication) {
// 验证用户的 UserDetails,为了获取用户名
Object principal = authentication.getPrincipal();
if(principal instanceof UserDetails) {
String username = ((UserDetails) principal).getUsername();
// 根据用户名查询其权限列表
List<String> urls = mapper.findUrlByUsername(username);
// 遍历 urls 是否存在权限
return urls.stream().anyMatch(
url -> matcher.match(url, request.getRequestURI()));
}
return false;
}
}
配置使用
// SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.formLogin()
.successHandler(successHandler)
.failureHandler(failHandler)
.and().authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/index").authenticated()
// 其他页面需要通过该类认证才可以访问
.anyRequest().access("@authorizeServiceImpl.hasPermission(request, authentication)");
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
权限表达式
常用权限表达式
表达式函数 | 描述 |
---|---|
hasRole([role]) | 拥有指定的角色返回 true tips: Spring Security 会给角色带上 ROLE_ |
hasAnyRole([role1, role2]) | 拥有任意一个角色返回 true |
hasAuthority([authority]) | 拥有指定权限则返回 true |
hasAnyAuthority([auth1, auth2]) | 拥有任意一个权限返回 true |
permitAll | 永远返回 true |
denyAll | 永远返回 false |
anonymous | 当用户是匿名用户时返回 true |
rememberMe | 当前用户是 rememberMe 用户时返回 true |
authentication | 当前用户是否登录认证成功 |
fullAuthenticated | 当前用户既不是 anonymous 也不是 rememberMe 时返回 true |
hasIpAress(‘192.168.1.1/24') | 请求发送的 ip 匹配时返回 true |
权限表达式在全局配置中的使用
.access("xxx")
方法级别的安全控制
Jsr - 250
注解
需要在 Spring Security 的配置类上开启
@EnableGlobalMethodSecurity(jsr250Enabled=true)
注解 | 描述 |
---|---|
@DenyAll | 拒绝所有访问 |
@RolesAllowed({“user", “admin"}) | 拥有 user 、admin 任意一个角色即可访问tips:这里可以省略 ROLE_ |
@PermitAll | 允许所有访问 |
secured
注解
需要在 Spring Security 的配置类上开启
@EnableGlobalMethodSecurity(securedEnabled=true)
注解 | 描述 |
---|---|
@Secured("ROLE_user","ROLE_admin") | 拥有 user 、admin 任意一个角色即可访问 |
IS_AUTHENTICATED_ANONYMOUSLY
表示允许匿名用户访问
prePost
注解 与 EL 表达式
需要在 Spring Security 的配置类上开启
@EnableGlobalMethodSecurity(prePostEnabled=true)
注解 | 描述 |
---|---|
@PreAuthorize | 在方法执行前执行,基于表达式结果来限制方法 |
@PostAuthorize | 在方法执行后执行,如果表达式结果为 false ,会抛出异常 |
@PreFilter | 在方法执行前执行,过滤参数 |
@PostFilter | 在方法执行后执行,过滤结果 |
表达式函数 | 描述 |
---|---|
hasRole([role]) | 拥有指定的角色返回 true tips: Spring Security 会给角色带上 ROLE_ |
hasAnyRole([role1, role2]) | 拥有任意一个角色返回 true |
hasAuthority([authority]) | 拥有指定权限则返回 true |
hasAnyAuthority([auth1, auth2]) | 拥有任意一个权限返回 true |
Principle | 代表当前用户的 principle 对象 |
authentication | 直接从 SecurityContext 获取的当前 Authentication 对象 |
permitAll | 永远返回 true |
denyAll | 永远返回 false |
isAnonymous() | 当前用户是否是匿名用户 |
isRememberMe() | 当前用户是否是 rememberMe 用户 |
isAuthenticated() | 当前用户是否登录认证成功 |
isFullyAuthenticated() | 当前用户既不是 anonymous 也不是 rememberMe 时返回 true |
// 示例
@PreAuthorize("hasRole('admin')") // 若没有 admin 角色,则抛出异常
public String findALl(){ return null}
@PostAuthorize("returnObject.username == #username") // username 为返回的 username 则不报错
public User find(@Param("username")String username) {
User user = new User();
user.setUsername(username);
return user;
}
@PreFilter(filterTarget = "ids", value = "filterObject%2 == 0") // 过滤 list 中的奇数
public String del(List<Integer> ids) {
return null;
}
// 过滤掉 list 中不在 authentication中的对象
@PostFilter("returnObject.name == authentication.name")
public List<String> findAllUser() {
List<User> list = new ArrayList<>();
list.add(new User("xxx"));
list.add(new User("yyy"));
return list;
}
RememberMe
功能
配置文件
前端需要参数名需要与
rememberMeParameter
一致,默认:remember-me
http.rememberMe() // 开启 rememberMe 功能(以下可选)
.rememberMeParameter("remember") // 指定传递的参数名(需与前端一致)
.rememberMeCookieName("remember") // 指定返回 cookie 中参数的名字
.tokenValiditySeconds(2*24*60*60) // 指定 rememberMe 过期时间
.tokenRepository(persistentTokenRepository()) // 数据持久化配置
数据持久化
创建表
CREATE TABLE `login_token` (
`series` varchar(64) NOT NULL,
`username` varchar(32) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` datetime NOT NULL COMMENT '需要自行设置',
PRIMARY KEY(`series`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
配置 persistentTokenRepository
// SecurityConfig.java
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
验证码
验证方式
session
存储验证码- 基于对称算法的验证码
Session 存储验证码
配置图片验证码工具类
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
配置类
// 更多配置详见下表
@Component
public class KaptchaConfig {
@Bean
public DefaultKaptcha getKaptcha() {
DefaultKaptcha kaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
kaptcha.setConfig(new Config(properties));
return kaptcha;
}
}
配置 | 描述 | 默认值 |
---|---|---|
kaptcha.border | 图片边框,合法值:yes , no | yes |
kaptcha.border.color | 边框颜色,合法值: r,g,b 或者 white,black,blue. | black |
kaptcha.border.thickness | 边框厚度,合法值:>0 | 1 |
kaptcha.image.width | 图片宽 | 200 |
kaptcha.image.height | 图片高 | 50 |
kaptcha.producer.impl | 图片实现类 | DefaultKaptcha |
kaptcha.textproducer.impl | 文本实现类 | DefaultTextCreator |
kaptcha.textproducer.char.string | 文本集合,验证码值从此集合中获取 | abcde2345678gfynmnpwx |
kaptcha.textproducer.char.length | 验证码长度 | 5 |
kaptcha.textproducer.font.names | 字体 | Arial, Courier |
kaptcha.textproducer.font.size | 字体大小 | 40px |
kaptcha.textproducer.font.color | 字体颜色,合法值: r,g,b 或者 white,black,blue. | black |
kaptcha.textproducer.char.space | 文字间隔 | 2 |
kaptcha.noise.impl | 干扰实现类 | DefaultNoise |
kaptcha.noise.color | 干扰颜色,合法值: r,g,b 或者 white,black,blue. | black |
kaptcha.obscurificator.impl | 图片样式: 水纹 WaterRipple 鱼眼 FishEyeGimpy 阴影 ShadowGimpy |
WaterRipple |
kaptcha.background.impl | 背景实现类 | DefaultBackground |
kaptcha.background.clear.from | 背景颜色渐变,开始颜色 | light grey |
kaptcha.background.clear.to | 背景颜色渐变, 结束颜色 | white |
kaptcha.word.impl | 文字渲染器 | DefaultWordRenderer |
kaptcha.session.key | session key | KAPTCHA_SESSION_KEY |
kaptcha.session.date | session date | KAPTCHA_SESSION_DATE |