Spring Security
一、介绍
Spring Security是一个功能强大且高度可定制的身份验证(authentication)和权限控制(access-control)框架。它是用于保护Spring的应用程序。Spring Security是一个专注于为Java应用程序提供身份验证和授权的框架。与所有Spring项目一样,Spring Security可以非常容易地扩展以满足定制需求。
二、简单使用
导入starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
编写测试接口:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello spring security";
}
}
进行登录,用户名默认为user
,密码为随机UUID
值在控制台输出
认证之后请求/hello访问成功
三、默认配置分析
导入Spring Security依赖之后,即可完成简单的认证,分析以下问题:
1.未认证时跳转到登录页面
DefaultLoginPageGeneratingFilter(默认登录页生成过滤器)
:
2.登录页面从何而来
由字符串拼接成登录页面以Response对象返回
//按照认证方式生成登录页
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
...
String contextPath = request.getContextPath();
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html>\n");
sb.append("<html lang=\"en\">\n");
sb.append(" <head>\n");
sb.append(" <meta charset=\"utf-8\">\n");
sb.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
sb.append(" <meta name=\"description\" content=\"\">\n");
sb.append(" <meta name=\"author\" content=\"\">\n");
sb.append(" <title>Please sign in</title>\n");
sb.append(" <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");
sb.append(" <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n");
sb.append(" </head>\n");
sb.append(" <body>\n");
sb.append(" <div class=\"container\">\n");
//其他认证方式
...
//如果是表单认证
if (this.formLoginEnabled) {
sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n");
sb.append(" <h2 class=\"form-signin-heading\">Please sign in</h2>\n");
sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + " <p>\n");
sb.append(" <label for=\"username\" class=\"sr-only\">Username</label>\n");
sb.append(" <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
sb.append(" </p>\n");
sb.append(" <p>\n");
sb.append(" <label for=\"password\" class=\"sr-only\">Password</label>\n");
sb.append(" <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n");
sb.append(" </p>\n");
sb.append(this.createRememberMe(this.rememberMeParameter) + this.renderHiddenInputs(request));
sb.append(" <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
sb.append(" </form>\n");
}
//其他认证方式
...
sb.append("</div>\n");
sb.append("</body></html>");
return sb.toString();
}
3.登录用户名密码在哪里存储
由于Spring Security默认进行formLogin
表单登录认证
UsernamePasswordAuthenticationFilter
中的attemptAuthentication
方法:
//ProviderManager中的方法
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
//获取当前ProviderManager中的每一个AuthenticationProvider
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
//看是否支持认证
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}
try {
//调用AuthenticationProvider进行真正的认证
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}
//如果当前的ProviderManager中的所有AuthenticationProvider都不支持认证
if (result == null && this.parent != null) {
try {
//交给它的父亲(全局的AuthenticationManager)去认证
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException var12) {
} catch (AuthenticationException var13) {
parentException = var13;
lastException = var13;
}
}
//如果有结果返回认证后的Authentication对象,否则抛出异常
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
} else {
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}
if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}
throw lastException;
}
}
交给全局的AuthenticationManager进行认证:
//AbstractUserDetailsAuthenticationProvider中的方法
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
//判断用户名是否为空
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
//尝试从缓存中获取用户信息进行比对
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
//本类中retrieveUser方法为抽象方法,这里实际上是调用子类 DaoAuthenticationProvider中重写的方法
//通过用户名查找用户信息(验证用户名),用户名不存在抛出异常
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw var6;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
//判断该用户是否启用、是否过期、是否锁定
this.preAuthenticationChecks.check(user);
//比较密码
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
//判断用户状态
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account is locked");
throw new LockedException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
} else if (!user.isEnabled()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account is disabled");
throw new DisabledException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
} else if (!user.isAccountNonExpired()) {
AbstractUserDetailsAuthenticationProvider.this.logger.debug("Failed to authenticate since user account has expired");
throw new AccountExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
}
}
//DaoAuthenticationProvider类中方法
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
//调用UserDetailsService的实现类的loadUserByUsername方法
//通过用户名查找用户信息
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}
//InMemoryUserDetailsManager类中
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//通过用户名查找用户信息
UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
} else {
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
}
登录用户存储在内存之中,可以通过配置文件进行修改
# 修改默认登录用户名和密码
spring:
security:
user:
name: root
password: root
四、自定义认证
默认配置生效的条件:
因此我们只需要定义一个WebSecurityConfigurerAdapter
让默认配置不生效
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//授权请求
http.authorizeRequests()
//放行所有静态资源路径
.mvcMatchers("/res/**").permitAll()
//放行登录请求
.mvcMatchers("/login").permitAll()
//其他所有请求都需要认证
.anyRequest().authenticated()
.and()
.formLogin()
//认证成功返回Json信息
.successHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "认证成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
//认证失败返回Json信息
.failureHandler(((request, response, exception) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 401);
map.put("msg", "认证失败!!!");
map.put("exception", exception);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
//定义注销的请求方式和请求路径可以有多个
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
//注销成功返回Json信息
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
//关闭跨站请求访问
.csrf().disable();
}
}
五、自定义数据源
1.源码分析
在前面我们已经分析出:
在AuthenticationManager
的实现类ProviderManager
中有一个AuthenticationProvider
的集合,遍历其中
的每一个Provider判断是否支持认证,如果支持则调用Provider中的authenticate
方法,
去UserDetailsService
中的loadUserByUsername()
方法中获取用户信息
因此,我们可以模仿Spring Security编写的User对象:
- 自定义一个User类实现UserDetails接口
2.自定义一个UserService类实现UserDetailsService接口,重写loadUserByUsername方法
3.将UserService配置到自定义的AuthenticationManager中
2.连接数据源
设计数据库表:
create table user
(
id varchar(50) not null comment '用户id'
primary key,
username varchar(50) not null comment '用户名',
password varchar(50) not null comment '密码',
deleted tinyint not null comment '逻辑删除字段',
account_expired int default 0 not null comment '用户是否过期',
account_locked int default 0 not null comment '用户是否锁定',
credentials_expired int default 0 not null comment '密码是否过期',
create_time datetime not null on update CURRENT_TIMESTAMP comment '创建时间',
update_time datetime not null on update CURRENT_TIMESTAMP comment '修改时间',
version bigint not null comment '乐观锁字段'
);
create table role
(
id int auto_increment comment '角色id'
primary key,
role varchar(50) not null comment '角色英文名',
name varchar(50) not null comment '角色中文名',
create_time datetime not null on update CURRENT_TIMESTAMP comment '创建时间',
update_time datetime not null on update CURRENT_TIMESTAMP comment '修改时间',
version bigint not null comment '乐观锁字段',
deleted tinyint not null comment '逻辑删除字段'
);
create table user_role
(
id int auto_increment comment '用户角色表主键'
primary key,
user_id varchar(50) not null comment '用户id',
role_id int not null comment '角色id',
create_time datetime not null on update CURRENT_TIMESTAMP comment '创建时间',
update_time datetime not null on update CURRENT_TIMESTAMP comment '修改时间',
version bigint not null comment '乐观锁字段',
deleted tinyint not null comment '逻辑删除字段'
);
导入相关依赖:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3.4</version>
</dependency>
<!-- 图片验证码 -->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
spring:
datasource:
url: jdbc:mysql://localhost:3306/spring_security?useSSL=false&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF8
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
druid:
aop-patterns: com.qingsongxyz.springsecurity # 配置Spring监控
filters: 'stat,wall'
stat-view-servlet:
enabled: true # 打开监控统计功能
login-username: admin
login-password: admin
reset-enable: true
web-stat-filter:
enabled: true # Web关联监控配置
filter:
stat:
enabled: true # 开启sql监控
wall:
enabled: true # 开启防火墙
db-type: mysql
config:
delete-allow: false
drop-table-allow: false
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 配置日志
kaptcha图片验证码配置类:
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptcha(){
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150"); //图片长度
properties.setProperty("kaptcha.image.height", "50"); //图片高度
//properties.setProperty("kaptcha.textproducer.char.string", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"); //验证码值从此集合中获取(大小写字母)
properties.setProperty("kaptcha.textproducer.char.string", "0123456789"); //验证码值从此集合中获取(数字)
properties.setProperty("kaptcha.textproducer.char.length", "4"); //验证码长度
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
//返回base64验证码
@Slf4j
@RestController
public class VerifyCodeController {
@Autowired
private Producer producer;
@GetMapping("/vc.png")
public String getVerifyCode(HttpSession session) throws IOException {
//1.生成验证码
String code = producer.createText();
//2.保存到session或redis
log.info("VerifyCodeController code:{}", code);
session.setAttribute("code", code);
log.info("VerifyCodeController session code:{}", session.getAttribute("code"));
//3.生成图片
BufferedImage image = producer.createImage(code);
FastByteArrayOutputStream fos = new FastByteArrayOutputStream();
//将图片写入输出流中
ImageIO.write(image, "png",fos);
//4.返回base64编码
return Base64.encodeBase64String(fos.toByteArray());
}
}
编写实体类:
@Data
@Accessors(chain = true)
@TableName("role")
@ApiModel(value = "Role对象", description = "")
public class Role extends Model<Role> {
@ApiModelProperty("角色id")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty("角色英文名")
@TableField("`role`")
private String role;
@ApiModelProperty("角色中文名")
@TableField("`name`")
private String name;
@ApiModelProperty("创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@ApiModelProperty("修改时间")
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@ApiModelProperty("乐观锁字段")
@TableField(value = "version", fill = FieldFill.INSERT)
@Version
private Long version;
@ApiModelProperty("逻辑删除字段")
@TableField(value = "deleted", fill = FieldFill.INSERT)
@TableLogic
private Integer deleted;
@Override
public Serializable pkVal() {
return this.id;
}
}
//User类实现UserDetails接口
@Data
@Accessors(chain = true)
@TableName("user")
@ApiModel(value = "User对象", description = "")
public class User extends Model<User> implements UserDetails {
@ApiModelProperty("用户id")
@TableId(value = "id", type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("用户名")
@TableField("username")
private String username;
@ApiModelProperty("密码")
@TableField("`password`")
private String password;
@ApiModelProperty("逻辑删除字段")
@TableField(value = "deleted", fill = FieldFill.INSERT)
@TableLogic
private Integer deleted;
@ApiModelProperty("用户是否过期")
@TableField("account_expired")
private Integer accountExpired;
@ApiModelProperty("用户是否锁定")
@TableField("account_locked")
private Integer accountLocked;
@ApiModelProperty("密码是否过期")
@TableField("credentials_expired")
private Integer credentialsExpired;
@ApiModelProperty("创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@ApiModelProperty("修改时间")
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@ApiModelProperty("乐观锁字段")
@TableField(value = "version", fill = FieldFill.INSERT)
@Version
private Long version;
//关系属性 存储当前用户的角色信息
@TableField(exist = false)
private List<Role> roleList = new ArrayList<>();
@Override
public Serializable pkVal() {
return this.id;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<SimpleGrantedAuthority> authorities = new HashSet<>();
roleList.forEach(role -> {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getRole());
authorities.add(simpleGrantedAuthority);
});
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return accountExpired == 0;
}
@Override
public boolean isAccountNonLocked() {
return accountLocked == 0;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsExpired == 0;
}
@Override
public boolean isEnabled() {
return deleted == 0;
}
}
UserMapper:
@Mapper
public interface UserMapper extends BaseMapper<User> {
List<Role> getRolesById(@RequestParam("userId") String userId);
}
UserMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingsongxyz.springsecurity.mapper.UserMapper">
<select id="getRolesById" resultType="com.qingsongxyz.springsecurity.pojo.Role">
select
r.id, r.role, r.name
from
role r
left join
user_role ur
on
r.id = ur.role_id
where
ur.user_id = #{userId}
</select>
</mapper>
UserService:
public interface UserService extends IService<User> {
}
UserServiceImpl:
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
@Autowired
private UserMapper userMapper;
public static final String ROLE_PREFIX = "ROLE_";
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<User> userWrapper = new QueryWrapper<>();
userWrapper.eq("username", username);
User user = userMapper.selectOne(userWrapper);
if(ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名不正确");
List<Role> roles = userMapper.getRolesById(user.getId());
//给角色信息添加前缀ROLE_
for (Role role : roles) {
role.setRole(ROLE_PREFIX + role.getRole());
}
user.setRoleList(roles);
return user;
}
}
验证码异常类:
public class VerifyCodeException extends AuthenticationException {
public VerifyCodeException(String msg, Throwable cause) {
super(msg, cause);
}
public VerifyCodeException(String msg) {
super(msg);
}
}
覆盖默认的UsernamePasswordAuthenticationFilter
:
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
public static final String FORM_CODE_KEY = "code";
private String codeParameter = FORM_CODE_KEY;
public String getCodeParameter() {
return codeParameter;
}
public void setCodeParameter(String codeParameter) {
this.codeParameter = codeParameter;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//判断是否为POST请求
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
//判断是否json格式请求
if(request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE))
{
try {
//获取登录信息
Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String code = userInfo.get(getCodeParameter());
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
//获取session中验证码
String sessionCode = (String)request.getSession().getAttribute("code");
log.info("code:{}, session code:{}", code, sessionCode);
//验证码一致
if(!ObjectUtils.isEmpty(code) && !ObjectUtils.isEmpty(sessionCode) && code.equals(sessionCode))
{
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
throw new VerifyCodeException("验证码不一致!!!");
} catch (IOException e) {
e.printStackTrace();
}
}
throw new InvalidParameterException("参数格式必须为json格式!!!");
}
}
在配置类中给自定义的AuthenticationManager注入数据源:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceImpl userServiceImpl;
//自定义的AuthenticationManager会覆盖SpringBoot自动配置的AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.userDetailsService(userServiceImpl);
}
//将自定义的AuthenticationManager暴露在容器中,使得在其他地方能够注入
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
//配置登录认证请求
loginFilter.setFilterProcessesUrl("/doLogin");
//注入自定义的AuthenticationManager
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setAuthenticationSuccessHandler(((request, response, authentication) > {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.OK); //200
map.put("msg", "登录成功!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
loginFilter.setAuthenticationFailureHandler(((request, response, exception) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.UNAUTHORIZED); //401
map.put("msg", "登录失败!!!");
map.put("exception", exception.getMessage());
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/res/**").permitAll()
.mvcMatchers("/login").permitAll()
.mvcMatchers("/vc.png").permitAll() //验证码放行
.anyRequest().authenticated()
.and()
.formLogin()
.and()
//未认证抛出异常,不显示默认登录页面
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) ->
{ response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.and()
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable();
//使用自定义的loginFilter覆盖默认的UsernamePasswordAuthenticationFilter
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
数据库中有三个用户:
用户名 | 密码 | 权限 |
---|---|---|
tom | 12345 | customer |
root | root | admin |
admin | admin | admin/super |
测试:
访问验证码接口获取base64编码
通过在线网站转换成图片:https://tool.chinaz.com/tools/imgtobase
六、密码加密
前面我们已经知道真正地认证过程由AbstractUserDetailsAuthenticationProvider
的
authenticate
方法完成,在该方法中先是调用retrieveUser
方法通过username加载用户信息,
然后调用preAuthenticationChecks.check
方法检查用户是否启用、是否过期、是否锁定,
调用**additionalAuthenticationChecks
**方法检查用户密码是否正确。
因此,我们只需要在存储用户密码时,存入{加密方式}对应加密方法后的密文
即可,代理类会自动识别不同加
密方式进行匹配
推荐使用BCrypt
加密:
public class BCryptPasswordEncoder implements PasswordEncoder {
private Pattern BCRYPT_PATTERN;
private final Log logger;
private final int strength;
private final BCryptPasswordEncoder.BCryptVersion version;
private final SecureRandom random;
//strength >= 4 && strength <= 31
public BCryptPasswordEncoder() {
this(-1);
/*
BCryptPasswordEncoder(-1);
BCryptPasswordEncoder(-1, (SecureRandom)null);
BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion.$2A, -1, (SecureRandom)null);
------------------------------------------------------------------
this.version = BCryptPasswordEncoder.BCryptVersion.$2A;
this.strength = 10;
this.random = null;
*/
}
public BCryptPasswordEncoder(int strength) {
this(strength, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version) {
this(version, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, SecureRandom random) {
this(version, -1, random);
}
public BCryptPasswordEncoder(int strength, SecureRandom random) {
this(BCryptPasswordEncoder.BCryptVersion.$2A, strength, random);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength) {
this(version, strength, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength, SecureRandom random) {
this.BCRYPT_PATTERN = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}");
this.logger = LogFactory.getLog(this.getClass());
if (strength == -1 || strength >= 4 && strength <= 31) {
this.version = version;
this.strength = strength == -1 ? 10 : strength;
this.random = random;
} else {
throw new IllegalArgumentException("Bad strength");
}
}
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
} else {
String salt = this.getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
}
...
//BCrypt版本号
public static enum BCryptVersion {
$2A("$2a"),
$2Y("$2y"),
$2B("$2b");
private final String version;
private BCryptVersion(String version) {
this.version = version;
}
public String getVersion() {
return this.version;
}
}
}
//加密后的密码长度为60(所以设计数据库时得保证该字段长度不小于60)
$2a$10$QwS9HMTOeDGRsezR7pT.jOD2xUYKgBpprCKzrzcymxj5zNkc4HTJO
$2y$10$ubOewGxo618uwZ8MugxyC.y/i5SxjZk0QwkgoBGNQy6L8W.uROVim
$2b$10$bA0f7n.9uvFbKQanB2nq.u8Y84c2CUVGqGVKYFzXZpRuQ7ONZtzY2
默认使用$2a
版本,循环加盐10次(最少4次,最多31次)
如果只使用一种加密方式并不会改变时,可以直接向容器中注入该类型的PasswordEncoder
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
......
}
注意:
固定死加密方式后,密码不需要添加{加密方式}
,直接使用加密后的密文,但是无法更新加密方式不灵活
密码自动升级:
更改加密方式重新进行登录后,将数据库中存储的原本加密方式加密的密码更新为新的加密方式加密的密码
//实现UserDetailsPasswordService接口
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService, UserDetailsPasswordService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<User> userWrapper = new QueryWrapper<>();
userWrapper.eq("username", username);
User user = userMapper.selectOne(userWrapper);
if(ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名不正确");
List<Role> roles = userMapper.getRolesById(user.getId());
user.setRoleList(roles);
return user;
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
//根据用户名更新密码
UpdateWrapper<User> wrapper = new UpdateWrapper<>();
wrapper.eq("username", user.getUsername());
wrapper.set("password", newPassword);
int update = userMapper.update((User) user, wrapper);
return user;
}
}
测试明文存储密码自动升级为Bcrypt加密的密码:
七、Remember me
1.源码分析
登录记住我,认证成功后服务器生成cookie向前端返回:
//成员变量
private String parameter = "remember-me";
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (this.alwaysRemember) { //如果总是开启rememberMe的话直接返回true
return true;
} else {
//获取请求参数remember-me的值 只有是"true"、"on"、"yes"、"1"时才返回true
String paramValue = request.getParameter(parameter);
if (paramValue == null || !paramValue.equalsIgnoreCase("true") && !paramValue.equalsIgnoreCase("on") && !paramValue.equalsIgnoreCase("yes") && !paramValue.equals("1")) {
this.logger.debug(LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
return false;
} else {
return true;
}
}
}
session30分钟过期登录信息失效后,携带名为remember-me
的cookie在服务器自动认证:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//如果用户认证过session没过期直接放行 否则尝试自动登录
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage.of(() -> {
return "SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'";
}));
chain.doFilter(request, response);
} else {
//调用AbstractRememberMeServices类的autoLogin方法自动登录
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
try {
//认证 设置登录用户信息
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
this.onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> {
return "SecurityContextHolder populated with remember-me token: '" + SecurityContextHolder.getContext().getAuthentication() + "'";
}));
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
} catch (AuthenticationException var6) {
this.logger.debug(LogMessage.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: '%s'; invalidating remember-me token", rememberMeAuth), var6);
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, var6);
}
}
chain.doFilter(request, response);
}
}
默认使用TokenBasedRememberMeServices
生成的cookie由用户名、过期时间、签名三部分组成,这个cookie
除非过期否则是不改变的,因此我们只需要携带此cookie在其他地方也能认证成功,安全性较低
我们可以使用另外一个实现类PersistentTokenBasedRememberMeServices
//series长度
private int seriesLength = 16;
//token长度
private int tokenLength = 16;
//得到随机16位进行base64加密后的series字符串
protected String generateSeriesData() {
byte[] newSeries = new byte[this.seriesLength];
this.random.nextBytes(newSeries);
return new String(Base64.getEncoder().encode(newSeries));
}
//得到随机16位进行base64加密后的token字符串
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));
//将用户名、series、token、当前时间封装成一个PersistentRememberMeToken对象
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
//创建一个token
this.tokenRepository.createNewToken(persistentToken);
//向前端返回cookie
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
session过期,自动登录过程验证remember-me
cookie并更新该cookie:
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
} else {
String presentedSeries = cookieTokens[0]; //获取series
String presentedToken = cookieTokens[1]; //获取token
//通过series获取封装的PersistentRememberMeToken信息
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
//如果token为null或者两个token不一致或者cookie超时过期都会抛出异常
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
} else if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
} else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
} else {
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries()));
//如果两个token相同,则将原来的series和一个新的token生成一个新的cookie返回给前端,并更新tokenRepository中的值
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
this.addCookie(newToken, request, response);
} catch (Exception var9) {
this.logger.error("Failed to update token: ", var9);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
//返回认证信息
return this.getUserDetailsService().loadUserByUsername(token.getUsername());
}
}
}
PersistentTokenBasedRememberMeServices
除了能更新cookie值外,还支持数据库存储,避免服务器宕机后
记住我失效
//成员变量 默认将PersistentRememberMeToken对象保存在内存中
private PersistentTokenRepository tokenRepository = new InMemoryTokenRepositoryImpl();
2.配置记住我
修改LoginFilter类:
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
public static final String FORM_CODE_KEY = "code";
public static final String FORM_REMEMBER_ME_KEY = "remember-me";
private String codeParameter = FORM_CODE_KEY;
private String rememberMeParameter = FORM_REMEMBER_ME_KEY;
public String getCodeParameter() {
return codeParameter;
}
public String getRememberMeParameter() {
return rememberMeParameter;
}
public void setRememberMeParameter(String rememberMeParameter) {
this.rememberMeParameter = rememberMeParameter;
}
public void setCodeParameter(String codeParameter) {
this.codeParameter = codeParameter;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//判断是否为POST请求
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
//判断是否json格式请求
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
try {
//获取登录信息
Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String code = userInfo.get(getCodeParameter());
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
String rememberMe = userInfo.get(getRememberMeParameter());
//获取session中验证码
String sessionCode = (String) request.getSession().getAttribute("code");
log.info("code:{}, session code:{}", code, sessionCode);
//验证码一致
if (!ObjectUtils.isEmpty(code) && !ObjectUtils.isEmpty(sessionCode) && code.equals(sessionCode)) {
//如果记住我 存入request中方便后续处理
if (!ObjectUtils.isEmpty(rememberMe)) {
request.setAttribute(getRememberMeParameter(), rememberMe);
}
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
throw new VerifyCodeException("验证码不一致!!!");
} catch (IOException e) {
e.printStackTrace();
}
}
throw new InvalidParameterException("参数格式必须为json格式!!!");
}
}
/**
* 前后端分离自定义接受RememberMe参数
*/
public class MyPersistentTokenBasedRememberMeServices extends PersistentTokenBasedRememberMeServices {
public MyPersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
super(key, userDetailsService, tokenRepository);
}
//重写rememberMeRequested方法
@Override
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
//由于在认证成功后才会生成cookie,loginFilter先获取了inputStream,此处无法重复获取,才通过request域获取rememberMe参数
String paramValue = request.getAttribute(parameter) == null ? null : request.getAttribute(parameter).toString();
if (paramValue == null || !paramValue.equalsIgnoreCase("true") && !paramValue.equalsIgnoreCase("on") && !paramValue.equalsIgnoreCase("yes") && !paramValue.equals("1")) {
return false;
} else {
return true;
}
}
}
Security配置类:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceImpl userServiceImpl;
@Autowired
private DataSource dataSource;
//注入JdbcTokenRepositoryImpl 进行cookie持久化
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
jdbcTokenRepository.setCreateTableOnStartup(false); //第一次启动需要设置为true 自动创建persistent_logins表 后续设置为false
return jdbcTokenRepository;
}
//注入自定义的RememberMeServices
@Bean
public RememberMeServices RememberMeServices(){
//key为UUID 数据源为mysql 持久化cookie
return new MyPersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userServiceImpl, persistentTokenRepository());
}
//自定义的AuthenticationManager会覆盖SpringBoot自动配置的AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.userDetailsService(userServiceImpl);
}
//将自定义的AuthenticationManager暴露在容器中,使得在其他地方能够注入
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setFilterProcessesUrl("/doLogin");
//配置自定义RememberMeServices生成cookie
loginFilter.setRememberMeServices(RememberMeServices());
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setAuthenticationSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.OK.value()); //200
map.put("msg", "登录成功!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
loginFilter.setAuthenticationFailureHandler(((request, response, exception) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.UNAUTHORIZED.value()); //401
map.put("msg", "登录失败!!!");
map.put("exception", exception.getMessage());
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/res/**").permitAll()
.mvcMatchers("/login").permitAll()
.mvcMatchers("/vc.png").permitAll()
.anyRequest().authenticated()
.and()
.rememberMe()
//配置使用自定义RememberMeServices验证cookie参数
.rememberMeServices(RememberMeServices())
.and()
.formLogin()
.and()
//未认证抛出异常,不显示默认登录页面
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.and()
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable();
//覆盖UsernamePasswordAuthenticationFilter
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
测试:
访问验证码接口:
八、会话管理(session)
1.并发session管理
成员变量,默认值为NullAuthenticatedSessionStrategy
会话认证策略 | 解释 |
---|---|
ChangeSessionIdAuthenticationStrategy | 仍使用原来的session,只修改下session id |
SessionFixationProtectionStrategy | 创建一个新的session,将原来session的属性迁移到新的session中 |
CompositeSessionAuthenticationStrategy | 组合多个SessionAuthenticationStrategy |
ConcurrentSessionControlAuthenticationStrategy | 限制同一用户同时登陆的次数 |
CsrfAuthenticationStrategy | 登陆成功之后,更换原来的csrf token |
NullAuthenticatedSessionStrategy | 空实现 |
RegisterSessionAuthenticationStrategy | 注册新session信息到SessionRegistry |
设置同一用户会话最大并发数maximumSessions
和会话过期回调expiredSessionStrategy
:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable()
.sessionManagement() //会话管理
.maximumSessions(1) //允许会话最大并发只能一个客户端
.expiredSessionStrategy(event -> { //设置用户被挤下线(会话过期)回调
HttpServletResponse response = event.getResponse();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value()); //500
map.put("msg", "当前会话已失效,请重新登录!!!");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
});
}
同一用户在不同浏览器登录(挤下线):
设置被挤下线后不能再次登录maxSessionsPreventsLogin
:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable()
.sessionManagement() //会话管理
.maximumSessions(1) //允许会话最大并发只能一个客户端
.expiredSessionStrategy(event -> { //设置用户被挤下线(会话过期)回调
HttpServletResponse response = event.getResponse();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value()); //500
map.put("msg", "当前会话已失效,请重新登录!!!");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
})
.maxSessionsPreventsLogin(true); //不允许再次登录
}
同一用户在不同浏览器登录(不允许再次登录):
2.集群下session共享
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
配置文件:
spring:
redis:
host: xxx
port: 6379
#password: xxx # 如果有密码需要配置
client-type: lettuce
#解决SpringBoot2.6以上版本和Swagger3的冲突问题
mvc:
pathmatch:
matching-strategy: ant_path_matcher
Redis配置类:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//方法已过时
//objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
Swagger配置类:
@Configuration
public class SwaggerConfig {
@Bean
public Docket createRestApi(){
return new Docket(DocumentationType.OAS_30)
.groupName("开发一组")
.select()
//.paths(PathSelectors.ant("/list/**")) //访问路径过滤
.apis(RequestHandlerSelectors.basePackage("com.qingsongxyz.springsecurity.controller")) //包过滤
.build()
.apiInfo(createApiInfo())
.enable(true);
}
@Bean
public ApiInfo createApiInfo() {
return new ApiInfo("qingsongxyz Swagger",
"qingsongxyz Api Documentation",
"3.0",
"http:xxx",
new Contact("qingsongxyz", "http:xxx", "xxx@qq.com"),
"Apache 2.0",
"http://www.apache.org/licenses/LICENSE-2.0",
new ArrayList());
}
}
登录控制类(用于测试):
@Controller
public class LoginController {
@PostMapping("/doLogin")
public void login(@RequestBody Map<String, String> map) {
}
}
SpringSecurity核心配置类:
@Resource
private FindByIndexNameSessionRepository repository;
//注入sessionRegistry
@Bean
public SessionRegistry sessionRegistry(){
return new SpringSessionBackedSessionRegistry<>(repository);
}
//设置SessionAuthenticationStrategy为ConcurrentSessionControlAuthenticationStrategy 控制并发session
@Bean
public SessionAuthenticationStrategy sessionAuthenticationStrategy(){
ConcurrentSessionControlAuthenticationStrategy strategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
strategy.setMaximumSessions(1); //同一账户只创建一次session
//strategy.setExceptionIfMaximumExceeded(true); //不允许重复登录
return strategy;
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setFilterProcessesUrl("/doLogin");
loginFilter.setRememberMeServices(RememberMeServices());
//配置SessionAuthenticationStrategy
loginFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy());
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setAuthenticationSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.OK.value()); //200
map.put("msg", "登录成功!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
loginFilter.setAuthenticationFailureHandler(((request, response, exception) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.UNAUTHORIZED.value()); //401
map.put("msg", "登录失败!!!");
map.put("exception", exception.getMessage());
//当产生的异常是SessionAuthenticationException时
if(exception instanceof SessionAuthenticationException)
{
map.put("exception", "该用户已经从处于登录状态!!!");
}
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/res/**").permitAll()
.mvcMatchers("/login").permitAll()
.mvcMatchers("/swagger-resources/**","/swagger-ui/**", "/v3/**", "/error").permitAll() //放行swagger相关资源
.mvcMatchers("/vc.png").permitAll()
.anyRequest().authenticated()
.and()
.rememberMe()
.rememberMeServices(RememberMeServices())
.and()
.formLogin()
.and()
//未认证抛出异常,不显示默认登录页面
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
})
.and()
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable()
.sessionManagement() //会话管理
.maximumSessions(1) //同一账户只创建一次session
.expiredSessionStrategy(event -> { //用户被挤下线处理
HttpServletResponse response = event.getResponse();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value()); //500
map.put("msg", "当前会话已失效,请重新登录!!!");
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
})
.sessionRegistry(sessionRegistry());
//覆盖UsernamePasswordAuthenticationFilter
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
测试两个项目运行在不同端口登录同一用户:
使用Postman访问8080登录:
使用Swagger访问8081登录:
设置可以挤下线输出提示信息:
设置setExceptionIfMaximumExceeded不允许同一用户重复登录:
session存储到redis中默认过期时间为30分钟(1800s):
spring:session
是默认的Redis HttpSession的前缀,每一个session都会创建3组数据:
- hash结构,spring:session:sessions存储主要内容
- String结构,spring:session:expires用于ttl过期时间
- set结构,spring:session:expirations过期时间记录,由于redis清除过期key的行为是一个异步行为且是一个低优先级的行为,可能会导致session不被清除,此项负责session的清除
spring.session.timeout
、server.servlet.session.timeout
都无法设置redis中session的过期时间,可以
通过@EnableRedisHttpSession
进行配置
@EnableOpenApi
@MapperScan("com.qingsongxyz.springsecurity.mapper")
@SpringBootApplication
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 300)
public class SpringSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityApplication.class, args);
}
}
九、跨域请求访问CSRF
跨站请求伪造(Cross-site request forgery),也被称为 one-click attack 或者 **session **
riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非
本意操作的攻击方法。
XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。
关闭Spring Security中Csrf防御,模拟Csrf攻击:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable(); //关闭csrf防御
}
转账handler:
@GetMapping("/withdraw")
public String withdraw(String name, Double money) {
return "成功向" + name + "转账" + money + "元...";
}
另一个页签,html中包含Csrf攻击:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSRF</title>
</head>
<body>
<!--请求域名需要一致-->
<form action="http://localhost:8080/withdraw" method="get">
<input hidden type="text" name="name" value="李四">
<input hidden type="text" name="money" value="10000">
<button>点击有惊喜!</button>
</form>
</body>
</html>
默认登录页面源代码:
开启Spring Security中Csrf防御(前后端分离):
服务器生成一个csrf token,每次响应请求时对该token进行验证
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.and()
//未认证抛出异常,不显示默认登录页面
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
})
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}))
.and()
.csrf() //开启csrf防御
//将令牌保存到cookie中,允许前端获取
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
CsrfFilter
类:
十、跨域配置CORS
CORS(Cross-Orgin Resource Sharing),同源 = 协议 + 主机 + 端口号(都相同)
新增一组Http请求头,除GET以外的请求浏览器必须先发送OPTIONS方式请求(预检请求prenightst),查看
服务器是否支持即将发送的跨域请求
请求头 | 说明 |
---|---|
Access-Control-Allow-Origin | 可以访问的域 |
Access-Control-Request-Methods | 发起跨域请求所使用的方法 |
Access-Control-Max-Age | 预检请求有效期,在预检之后的这段时间内不会再次发送预检请求 |
1.Spring跨域方式
新建子模块TestCors用于测试跨域
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
控制器:
@RestController
public class TestController {
@GetMapping("/test")
public String test(){
return "success...";
}
}
前端页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>跨域请求</title>
<script src="axios.js"></script>
</head>
<body>
<h1>跨域请求测试</h1>
<h1 class="content"></h1>
<script>
var content = document.getElementsByClassName("content")[0];
axios.get(`http://localhost:8080/test`).then(
response => {
content.textContent = response.data
},
error => {
content.textContent = error.message
}
)
</script>
</body>
</html>
启动访问:
第一种解决方案:
在控制器添加@CrossOrigin
注解
@RestController
@CrossOrigin //设置允许跨域
public class TestController {
@GetMapping("/test")
public String test(){
return "success...";
}
}
第二种解决方案(全局允许跨域):
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") //所有请求允许跨域
.allowedHeaders("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(false)
.exposedHeaders("")
.maxAge(1800L); //设置预检请求时间为30分钟
}
}
第三种解决方案(全局允许跨域):
@Configuration
public class MyCorsFilter {
@Bean
public FilterRegistrationBean<CorsFilter> corsFilterFilterRegistrationBean(){
FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
corsConfiguration.setAllowedMethods(Arrays.asList("*"));
corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
corsConfiguration.setMaxAge(1800L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
registrationBean.setFilter(new CorsFilter(source));
registrationBean.setOrder(-1);
return registrationBean;
}
}
2.Spring Security跨域
添加security依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
启动测试Spring跨域方式失效,前两种请求被Spring Security拦截需要进行认证,第三种注册Filter需要在
Seucity FilterChain中的所有Filter之前执行才能成功
解决方法:
security配置类:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
//用户信息存储在内存中
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("root").password("{noop}root").roles("admin", "super").build());
return userDetailsService;
}
//跨域配置
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
corsConfiguration.setAllowedMethods(Arrays.asList("*"));
corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
corsConfiguration.setMaxAge(1800L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.and()
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.cors()
.configurationSource(corsConfigurationSource()) //指定跨域
.and()
.csrf().disable();
}
}
测试:
十、Security异常处理
认证异常(AuthenticationException):
授权异常(AccessDeniedException):
Security配置类:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
//用户信息存储在内存中
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("root").password("{noop}root").roles("admin", "super").build());
userDetailsService.createUser(User.withUsername("customer").password("{noop}123456").roles("customer").build());
return userDetailsService;
}
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
corsConfiguration.setAllowedMethods(Arrays.asList("*"));
corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
corsConfiguration.setMaxAge(1800L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/test").hasRole("admin")
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value()); //403
response.getWriter().println("没有权限访问!!!");
response.flushBuffer();
})
.and()
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.cors()
.configurationSource(corsConfigurationSource()) //指定跨域
.and()
.csrf().disable();
}
}
启动测试:
customer登录访问/test
root登录访问/test
十一、授权
权限管理方式(RBAC):
基于角色的权限管理(Role Base Access Control):用户 - 角色 - 资源
基于资源的权限管理(Resource Base Access Control):用户 - 权限 - 资源
Spring Security中的权限管理策略:
- 基于
URL
权限管理(FilterSecurityInterceptor)- 通过过滤器实现,拦截HTTP请求,根据地址进行权限校验
- 基于
方法
权限管理(MethodSecurityInterceptor)- 当调用方法时,通过AOP将操作拦截下来,进行权限校验
1.URL权限管理
控制器:
@RestController
@CrossOrigin //设置跨域
public class TestController {
@GetMapping("/test")
public String test(){
return "success...";
}
@GetMapping("/super")
public String super_(){
return "super homePage...";
}
@GetMapping("/admin")
public String admin(){
return "admin homePage...";
}
@GetMapping("/customer")
public String customer(){
return "customer homePage...";
}
@GetMapping("/info")
public String info(){
return "info content...";
}
}
Security配置类:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
//用户信息存储在内存中
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("root").password("{noop}root").roles("admin", "super").build()); //创建root用户具有admin、super角色
userDetailsService.createUser(User.withUsername("customer").password("{noop}customer").roles("customer").build()); //创建customer用户具有customer角色
userDetailsService.createUser(User.withUsername("zhangsan").password("{noop}123456").authorities("read_info_content").build()); //创建zhangsan用户具有read_info_content权限
return userDetailsService;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.mvcMatchers("/super").hasRole("super") //访问super接口需要super角色
.mvcMatchers("/admin").hasRole("admin") //访问admin接口需要admin角色
.mvcMatchers("/customer").hasRole("customer") //访问customer接口需要customer角色
.mvcMatchers("/info").hasAuthority("read_info_content") //访问info接口需要read_info_content权限
.anyRequest().authenticated()
.and()
.formLogin()
.defaultSuccessUrl("/test") //认证成功跳转到/test路径
.and()
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value()); //403
response.getWriter().println("没有权限访问!!!");
response.flushBuffer();
})
.and()
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable();
}
}
启动测试:
root用户登录:
customer用户登录:
zhangsan用户登录:
权限表达式(SecurityExpressionOperations接口):
方法 | 说明 |
---|---|
hasAuthority(String authority) | 是否具有指定权限 |
hasAnyAuthority(String… authorities) | 是否具有指定数组中任意一个权限 |
hasRole(String role) | 是否具有指定角色 |
hasAnyRole(String… roles) | 是否具有指定数组中任意一个角色 |
permitAll() | 放行所有请求 |
denyAll() | 拒绝所有请求 |
isAnonymous() | 是否是一个匿名用户 |
isAuthenticated() | 是否认证成功 |
isRememberMe() | 是否通过记住我自动登录 |
isFullyAuthenticated() | 是否既不是匿名用户也不是通过记住我自动登录 |
hasPermission(Object target, Object permission) | 是否具有指定目标的指定权限 |
hasPermission(Object targetId, String targetType, Object permission) | 是否具有指定目标的指定权限 |
AbstractRequestMatcherRegistry类中:
antMatchers只会匹配指定的路径
mvcMatchers:
regexMatchers:
2.方法权限管理
URL权限管理通过FilterSecurityInterceptor过滤器只能在请求前进行处理,而方法权限管理通过AOP可以在
请求前后都进行处理
@EnableGlobalMethodSecurity
:开启权限注解进行方法权限管理
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import({ GlobalMethodSecuritySelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableGlobalMethodSecurity {
//是否开启Spring Security提供的权限注解(默认不开启)
boolean prePostEnabled() default false;
//是否开启Spring Security提供的@Secured(默认不开启)
boolean securedEnabled() default false;
//是否开启JSR-250提供的注解(默认不开启)
boolean jsr250Enabled() default false;
boolean proxyTargetClass() default false;
AdviceMode mode() default AdviceMode.PROXY;
int order() default Ordered.LOWEST_PRECEDENCE;
}
权限注解:
注解 | 说明 | 来源 | 是否支持权限表达式 |
---|---|---|---|
@PreAuthorize | 在目标方法执行前进行权限校验 | Spring Security | 是 |
@PostAuthorize | 在目标方法执行后进行权限校验 | Spring Security | 是 |
@PreFilter | 在目标方法执行前对方法参数进行过滤 | Spring Security | 是 |
@PostFilter | 在目标方法执行后对返回结果进行过滤 | Spring Security | 是 |
@Secured | 访问目标方法具有相应的角色 | Spring Security | 否 |
@DenyAll | 拒绝所有访问 | JSR250 | 否 |
@PermitAll | 允许所有访问 | JSR250 | 否 |
@RolesAllowed | 访问目标方法具有相应的角色 | JSR250 | 否 |
修改Security配置类开启权限注解:
@Configuration
//开启所有权限注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
......
}
控制器:
@RestController
public class RoleController {
@PreAuthorize("hasRole('customer')")
@GetMapping("/hello")
public String hello(){
return "hello";
}
//登录的用户名称是否和参数一致
@PreAuthorize("authentication.name==#name")
@GetMapping("/name")
public String hello(String name){
return "hello " + name;
}
//filterObject为参数people
@PreFilter(value = "filterObject.gender.equals('男')", filterTarget = "people")
@PostMapping("/people")
public void addPeople(@RequestBody List<Person> people){
System.out.println(people);
}
@PostAuthorize(value = "returnObject.age==18") //returnObject为返回值 年龄是否为18
@GetMapping("/person")
public Person getPerson(Integer age){
return new Person("张三", age, "男");
}
@PostFilter(value = "filterObject.age%2==0") //filterObject为返回值
@GetMapping("/list/people")
public List<Person> getPeople(){
ArrayList<Person> people = new ArrayList<>();
for (int i = 1; i < 5; i++) {
people.add(new Person("王" + i, i, "男"));
}
return people;
}
@Secured({"ROLE_admin", "ROLE_customer"}) //具有任意一个角色即可访问
@GetMapping("/secured")
public String secured(){
return "secured...";
}
@PermitAll
@GetMapping("/permitAll")
public String permitAll(){
return "permitAll...";
}
@DenyAll
@GetMapping("/denyAll")
public String denyAll(){
return "denyAll...";
}
@RolesAllowed({"ROLE_admin", "ROLE_customer"}) //具有任意一个角色即可访问
@GetMapping("/rolesAllowed")
public String rolesAllowed(){
return "rolesAllowed...";
}
}
customer用户登录:
3.原理分析
URL权限管理分析:
4.基于角色的权限管理
数据库创建资源路径表和角色路径表:
CREATE TABLE "pattern" (
"id" int NOT NULL AUTO_INCREMENT COMMENT '资源路径id',
"pattern" varchar(255) NOT NULL COMMENT '资源路径',
"create_time" datetime NOT NULL COMMENT '创建时间',
"update_time" datetime NOT NULL COMMENT '修改时间',
"version" bigint NOT NULL DEFAULT '0' COMMENT '乐观锁字段',
"deleted" tinyint NOT NULL COMMENT '逻辑删除字段',
PRIMARY KEY ("id")
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8
CREATE TABLE "role_pattern" (
"id" int NOT NULL AUTO_INCREMENT COMMENT '角色路径表id',
"pattern_id" int NOT NULL COMMENT '资源路径id',
"role_id" int NOT NULL COMMENT '角色id',
"create_time" datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
"update_time" datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
"version" bigint NOT NULL DEFAULT '0' COMMENT '乐观锁字段',
"deleted" tinyint NOT NULL COMMENT '逻辑删除字段',
PRIMARY KEY ("id")
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8
插入数据:
@SpringBootTest
class SpringSecurityApplicationTests {
@Autowired
private PatternMapper patternMapper;
@Autowired
private RolePatternMapper rolePatternMapper;
@Test
void addPattern(){
Pattern pattern = new Pattern();
pattern.setPattern("/super/**");
Pattern pattern1 = new Pattern();
pattern1.setPattern("/admin/**");
Pattern pattern2 = new Pattern();
pattern2.setPattern("/customer/**");
patternMapper.insert(pattern);
patternMapper.insert(pattern1);
patternMapper.insert(pattern2);
}
@Test
void addRolePattern(){
RolePattern rolePattern = new RolePattern();
rolePattern.setRoleId(3);
rolePattern.setPatternId(1);
RolePattern rolePattern1 = new RolePattern();
rolePattern1.setRoleId(2);
rolePattern1.setPatternId(2);
RolePattern rolePattern2 = new RolePattern();
rolePattern2.setRoleId(1);
rolePattern2.setPatternId(3);
rolePatternMapper.insert(rolePattern);
rolePatternMapper.insert(rolePattern1);
rolePatternMapper.insert(rolePattern2);
}
}
数据库信息:
用户名 | 密码 | 权限 |
---|---|---|
tom | 12345 | customer |
root | root | admin |
admin | admin | admin/super |
资源路径 | 权限 |
---|---|
/super/** | super |
/admin/** | admin |
/customer/** | customer |
实体类:
@Getter
@Setter
@Accessors(chain = true)
@TableName("pattern")
@ApiModel(value = "Pattern对象", description = "")
public class Pattern extends Model<Pattern> {
@ApiModelProperty("资源路径id")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty("资源路径")
@TableField("pattern")
private String pattern;
@ApiModelProperty("创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@ApiModelProperty("修改时间")
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@ApiModelProperty("乐观锁字段")
@TableField(value = "version", fill = FieldFill.INSERT)
@Version
private Long version;
@ApiModelProperty("逻辑删除字段")
@TableField(value = "deleted", fill = FieldFill.INSERT)
@TableLogic
private Integer deleted;
//关系属性 资源路径所需的角色信息
@TableField(exist = false)
private List<Role> roleList;
@Override
public Serializable pkVal() {
return this.id;
}
}
@Getter
@Setter
@Accessors(chain = true)
@TableName("role_pattern")
@ApiModel(value = "RolePattern对象", description = "")
public class RolePattern extends Model<RolePattern> {
@ApiModelProperty("角色路径表id")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty("资源路径id")
@TableField("pattern_id")
private Integer patternId;
@ApiModelProperty("角色id")
@TableField("role_id")
private Integer roleId;
@ApiModelProperty("创建时间")
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@ApiModelProperty("修改时间")
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@ApiModelProperty("乐观锁字段")
@TableField(value = "version", fill = FieldFill.INSERT)
@Version
private Long version;
@ApiModelProperty("逻辑删除字段")
@TableField(value = "deleted", fill = FieldFill.INSERT)
@TableLogic
private Integer deleted;
@Override
public Serializable pkVal() {
return this.id;
}
}
PatternMapper:
@Mapper
public interface PatternMapper extends BaseMapper<Pattern> {
List<Pattern> getPatternList();
}
PatternMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingsongxyz.springsecurity.mapper.PatternMapper">
<select id="getPatternList" resultMap="BaseResultMap">
select
p.id, p.pattern, r.role, r.name
from pattern p
left join
role_pattern rp
on
rp.pattern_id = p.id
left join
role r
on
rp.role_id = r.id
</select>
</mapper>
PatternService:
public interface PatternService extends IService<Pattern> {
List<Pattern> getPatternList();
}
PatternServiceImpl:
@Service
public class PatternServiceImpl extends ServiceImpl<PatternMapper, Pattern> implements PatternService {
@Autowired
private PatternMapper patternMapper;
@Override
public List<Pattern> getPatternList() {
return patternMapper.getPatternList();
}
}
自定义SecurityMetadataSource从数据库读取权限信息:
@Component
public class MySecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
private PatternService patternService;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
public static final String ROLE_PREFIX = "ROLE_";
/**
* 获取数据库中的资源路径信息
* @param object 路径信息
* @return 资源路径信息对应的角色
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
//获取请求路径
String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
//查询数据库中所有的资源路径(包括所需角色信息)
List<Pattern> patternList = patternService.getPatternList();
for (Pattern pattern : patternList) {
//遍历每一个资源路径和请求路径比较
if(antPathMatcher.match(pattern.getPattern(), requestURI))
{
//匹配成功 获取请求路径需要的角色信息(添加前缀)
String[] roleList = pattern.getRoleList().stream().map(r -> ROLE_PREFIX + r.getRole()).toArray(String[]::new);
return SecurityConfig.createList(roleList);
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
/**
* 判断是否支持该类型的数据
* @param clazz
* @return
*/
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
Security配置类:
@Configuration
public class MyWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceImpl userServiceImpl;
@Autowired
private MySecurityMetadataSource mySecurityMetaSource;
//自定义的AuthenticationManager会覆盖SpringBoot自动配置的AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder.userDetailsService(userServiceImpl);
}
//将自定义的AuthenticationManager暴露在容器中,使得在其他地方能够注入
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
//配置登录认证请求
loginFilter.setFilterProcessesUrl("/doLogin");
//注入自定义的AuthenticationManager
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setAuthenticationSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.OK.value()); //200
map.put("msg", "登录成功!");
map.put("authentication", authentication);
String json = JSON.toJSONString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
loginFilter.setAuthenticationFailureHandler(((request, response, exception) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", HttpStatus.UNAUTHORIZED.value()); //401
map.put("msg", "登录失败!!!");
map.put("exception", exception.getMessage());
//当产生的异常是SessionAuthenticationException时
if(exception instanceof SessionAuthenticationException)
{
map.put("exception", "该用户已经处于登录状态!!!");
}
String json = JSON.toJSONString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}));
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//设置自定义权限源数据(放在配置的最上面不然可能不生效)
//1.获取工厂对象
ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
//2.应用基于Url的权限配置
http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
//设置自定义权限源数据
object.setSecurityMetadataSource(mySecurityMetaSource);
//是否拒绝公共资源(无需权限限制的资源)访问
//object.setRejectPublicInvocations(true);
return object;
}
});
http.authorizeRequests()
.mvcMatchers("/res/**").permitAll()
.mvcMatchers("/login").permitAll()
.mvcMatchers("/swagger-resources/**","/swagger-ui/**", "/v3/**", "/error").permitAll() //放行swagger
.mvcMatchers("/vc.png").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
//未认证抛出异常,不显示默认登录页面
.exceptionHandling()
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value()); //401
response.getWriter().println("请先进行认证!");
response.flushBuffer();
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpStatus.FORBIDDEN.value()); //403
response.getWriter().println("没有权限访问!!!");
response.flushBuffer();
})
.and()
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.logout().logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", "GET"),
new AntPathRequestMatcher("/logout", "POST")
))
.logoutSuccessHandler(((request, response, authentication) -> {
Map<String, Object> map = new HashMap<>();
map.put("code", 200);
map.put("msg", "注销成功!!!");
map.put("authentication", authentication);
String json = JSON.toJSONString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
response.flushBuffer();
}))
.and()
.csrf().disable();
//覆盖UsernamePasswordAuthenticationFilter
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
启动测试:
十二、OAuth
OAUTH(Open Authorization)是一个用于用户资源授权的安全、开放又简易的协议。与以往的授权方式不同
OAUTH不会让第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户名与密码就可以申请
获得该用户资源的授权。
1.四种授权模式
RFC6749文档:https://www.rfc-editor.org/rfc/rfc6749
OAuth授权流程图:
Client:第三方应用
Resource Owner:资源拥有者(用户)
Authorization Server:授权服务器
Resource Server:资源服务器
步骤:
(A)打开客户端访问第三方应用,需要用户进行授权
(B)用户同意使用信赖的网站进行OAuth授权
©第三方应用向信赖网站的授权服务器申请令牌
(D)授权服务器认证以后发放令牌
(E)第三方应用使用该令牌向信赖网站的资源服务器申请用户相关信息
(F)资源服务器确认令牌无误,将用户信息交给第三方应用
Authorization Code(授权码模式):
功能最完整、流程最严密、最安全并广泛使用的授权模式
User Agent:用户代理(浏览器)
步骤:
(A)通过浏览器访问第三方应用需要用户进行授权,携带client id、scope、state和redirect uri导向授权服务器
(B)授权服务器验证用户身份并确定用户是否接受或拒绝客户端的授权请求
©如果用户接受授权,授权服务器将导向指定的redirect uri并返回授权码
(D)客户端收到授权码,携带redirect uri,向授权服务器申请令牌
(E)授权服务器核对授权码和redirect uri,如果无误则返回令牌(access token)和更新令牌(refresh token)
https://github.com/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=read
参数 | 解释 |
---|---|
client_id | 授权服务器注册应用唯一标识 |
response_type | 用于请求授权端点,code授权码模式,token简化模式,password密码模式,client_credentials客户端模式 |
grant_type | 用于请求令牌端点 |
重定向uri | |
scope | 令牌可以访问的资源(read只读、all读写) |
state | 防止XSRF攻击 |
Implicit Grant(简化模式):
跳过授权码直接申请令牌
步骤:
(A)通过浏览器访问第三方应用需要用户进行授权,携带client id、scope、state和redirect uri导向授权服务器
(B)授权服务器验证用户身份并确定用户是否接受或拒绝客户端的授权请求
©如果用户接受授权,授权服务器将用户导向redirect uri并在uri的hash部分(#)包含访问令牌
(D)浏览器请求资源服务器但不携带令牌
(E)资源服务器返回一个网页,其中的脚本可以获取hash中的令牌
(F)浏览器执行脚本提取出令牌
(G)浏览器将令牌发给客户端
https://github.com/oauth/authorize?response_type=token&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=read
Resource Owner Password Credentials Grant(密码模式):
步骤:
(A)用户向客户端提供用户名和密码
(B)客户端将用户名和密码提供给授权服务器请求令牌
©授权服务器认证用户身份无误返回令牌
https://github.com/oauth/authorize?grant_type=password&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=read
Client Credentials Grant(客户端模式)
步骤:
(A)客户端向授权服务器进行身份验证,并且申请令牌
(B)授权服务器对客户端进行身份验证,如果无误发送令牌
https://github.com/oauth/authorize?grant_type=client_credentials&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI&scope=read
2.简单授权登录
OAuth2.0 标准接口:
- /oauth/authorize授权端点
- /oauth/token令牌端点
- /oauth/confirm_access 用户确认授权提交端点
- /oauth/error授权服务错误信息端点
- /oauth/check_token资源服务访问的令牌解析端点
- /oauth/token_key使用JWT令牌提供公钥的端点
创建子模块OAuthTest
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- oauth客户端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Security配置类:
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login() //开启oauth登录
.and();
}
}
控制器:
@RestController
public class TestController {
/**
* 获取认证后的用户信息
*/
@GetMapping("/info")
public DefaultOAuth2User test(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (DefaultOAuth2User) authentication.getPrincipal();
}
}
GitHub注册OAuth应用:
application.yml配置文件:
server:
port: 8080
spring:
application:
name: OAuthTest
security:
oauth2:
client:
registration:
github: #client-id、client-secret、redirect-uri必须和注册时的一致
client-id: xxx
client-secret: xxx
redirect-uri: http://localhost:8080/login/oauth2/code/github
启动访问:
Gitee注册OAuth应用:
application.yml配置文件:
spring:
security:
oauth2:
client:
registration:
gitee: # client-id、client-secret、redirect-uri必须和注册时的一致
provider: gitee
client-id: xxx
client-secret: xxx
authorization-grant-type: authorization_code # 授权码模式
redirect-uri: http://localhost:8080/login/oauth2/code/gitee
provider: # gitee认证需要配置对应的接口路径
gitee:
authorization-uri: https://gitee.com/oauth/authorize
token-uri: https://gitee.com/oauth/token
user-info-uri: https://gitee.com/api/v5/user
user-name-attribute: "name"
Gitee官方文档:https://gitee.com/api/v5/swagger#/getV5ReposOwnerRepoStargazers?ex=no
启动访问:
3.原理分析
当用户同意授权后进入attemptAuthentication
方法:
4.搭建授权 / 资源服务器
常见的授权服务器:
- keycloak:针对现代应用程序和服务的开源身份和访问管理解决方案,支持单点登录,可以通过 OpenID Connect、OAuth 2.0 等协议对接 Keycloak
- Apach Oltu:
使用spring cloud
搭建授权服务器,spring security
搭建资源服务器
内存实现
授权服务器
创建子模块AuthorizationServer
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 授权服务器依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
application.yml:
# 应用服务 WEB 访问端口
server:
port: 8080
# 应用名称
spring:
application:
name: AuthorizationServer
Security配置类:
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
/**
* 配置内存用户
*/
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("admin").password("{bcrypt}" + passwordEncoder.encode("admin")).roles("Admin").build());
return userDetailsService;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable();
}
}
MyInMemoryAuthorizationServer配置类:
@Configuration
@EnableAuthorizationServer //指定本应用为授权服务器
public class MyInMemoryAuthorizationServer extends AuthorizationServerConfigurerAdapter {
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private AuthenticationManager authenticationManager;
/**
* 令牌存储在内存中
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client")
.secret("{bcrypt}" + passwordEncoder.encode("secret"))
.redirectUris("http://www.baidu.com") //重定向URI
.authorizedGrantTypes("authorization_code", "refresh_token", "implicit", "password", "client_credentials") //授权码模式、刷新令牌、简化模式、密码模式、客户端模式
.scopes("read:user"); //令牌允许对用户做的操作
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.userDetailsService(userDetailsService); //配置userDetailsService
endpoints.authenticationManager(authenticationManager); //配置authenticationManager
}
}
application.yml:
# 应用服务 WEB 访问端口
server:
port: 8081
# 应用名称
spring:
application:
name: ResourceServer
启动测试:
授权码模式:
http://localhost:8080/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com
获取令牌:
curl -X POST -H "Content-type: application/x-www-form-urlencoded" -d "grant_type=authorization_code&code=PE1XYu(授权码)&redirect_uri=http://www.baidu.com" http://client:secret@localhost:8080/oauth/token
返回access_token和refresh_token,过期时间:
刷新令牌:
curl -X POST -H "Content-type: application/x-www-form-urlencoded" -d "grant_type=refresh_token&refresh_token=c3027ec0-c3e0-4924-98bf-9d5e8abbc2ec(refresh_token)&code=JZEVkm&client_id=client" http://client:secret@localhost:8080/oauth/token
不支持简化模式,密码模式:
获取令牌:
curl -X POST -H "Content-type: application/x-www-form-urlencoded" -d "grant_type=password&username=admin&password=admin" http://client:secret@localhost:8080/oauth/token
刷新令牌:
curl -X POST -H "Content-type: application/x-www-form-urlencoded" -d "grant_type=refresh_token&refresh_token=c3027ec0-c3e0-4924-98bf-9d5e8abbc2ec&code=JZEVkm&client_id=client" http://client:secret@localhost:8080/oauth/token
客户端模式:
获取令牌:
curl -X POST -H "Content-type: application/x-www-form-urlencoded" -d "grant_type=client_credentials" http://client:secret@localhost:8080/oauth/token
数据库实现
SQL:https://github.com/spring-attic/spring-security-oauth/blob/main/spring-security-oauth2/src/test/resources/schema.sql
**注意:**需要将LONGVARBINARY
类型改为BLOB
,如下
create table oauth_client_details (
client_id VARCHAR(256) PRIMARY KEY,
resource_ids VARCHAR(256),
client_secret VARCHAR(256),
scope VARCHAR(256),
authorized_grant_types VARCHAR(256),
web_server_redirect_uri VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(256)
);
create table oauth_client_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256)
);
create table oauth_access_token (
token_id VARCHAR(256),
token BLOB,
authentication_id VARCHAR(256) PRIMARY KEY,
user_name VARCHAR(256),
client_id VARCHAR(256),
authentication BLOB,
refresh_token VARCHAR(256)
);
create table oauth_refresh_token (
token_id VARCHAR(256) PRIMARY KEY,
token BLOB,
authentication blob
);
create table oauth_code (
code VARCHAR(256), authentication BLOB
);
create table oauth_approvals (
userId VARCHAR(256),
clientId VARCHAR(256),
scope VARCHAR(256),
status VARCHAR(10),
expiresAt TIMESTAMP,
lastModifiedAt TIMESTAMP
);
-- customized oauth_client_details table
create table ClientDetails (
appId VARCHAR(256) PRIMARY KEY,
resourceIds VARCHAR(256),
appSecret VARCHAR(256),
scope VARCHAR(256),
grantTypes VARCHAR(256),
redirectUrl VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additionalInformation VARCHAR(4096),
autoApproveScopes VARCHAR(256)
);
-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
-- 密码为secret 经过bcrypt加密
INSERT INTO `oauth_client_details` VALUES ('client', null, '{bcrypt}$2a$10$OFXjGFb1jCdG9XXH0mAYWuoxnhpDCyYjsLUMmpEqY1695ZN87vEmq', 'read', 'authorization_code,refresh_token,implicit,password,client_credentials', 'http://www.baidu.com', '', null, null, null, null);
授权服务器:
添加连接数据库依赖:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
application.yml:
# 应用服务 WEB 访问端口
server:
port: 8080
# 应用名称
spring:
application:
name: AuthorizationServer
datasource:
url: jdbc:mysql://localhost:3306/oauth?useSSL=false&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF8
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
MyJdbcAuthorizationServer配置类(数据库存储token):
@Configuration
@EnableAuthorizationServer
public class MyJdbcAuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private AuthenticationManager authenticationManager;
@Bean
public ClientDetailsService jdbcClientDetailsService(){
JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
jdbcClientDetailsService.setPasswordEncoder(new BCryptPasswordEncoder());
return jdbcClientDetailsService;
}
/**
* @return jdbc存储token
*/
@Bean
public TokenStore tokenStore(){
return new JdbcTokenStore(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(jdbcClientDetailsService());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager); //配置认证管理器
endpoints.tokenStore(tokenStore()); //配置令牌存储
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true); //支持令牌刷新
defaultTokenServices.setReuseRefreshToken(true); //重复使用刷新令牌
defaultTokenServices.setClientDetailsService(endpoints.getClientDetailsService());
defaultTokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
//令牌过期时间30天
defaultTokenServices.setAccessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(30));
//刷新令牌有效性(以秒为单位)
defaultTokenServices.setRefreshTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(3));
endpoints.tokenServices(defaultTokenServices); //配置令牌服务
}
}
资源服务器和授权服务器用同一个数据库避免httpclient网络超时
创建子模块ResourceServer
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- 资源服务器依赖 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
application.yml:
# 应用服务 WEB 访问端口
server:
port: 8081
# 应用名称
spring:
application:
name: ResourceServer
datasource:
url: jdbc:mysql://localhost:3306/oauth?useSSL=false&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF8
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
@Configuration
@EnableResourceServer
public class MyJdbcResourceServer extends ResourceServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Bean
public TokenStore tokenStore(){
return new JdbcTokenStore(dataSource);
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore());
}
}
控制器(受限资源模拟用户信息):
@RestController
public class PrivacyController {
@GetMapping("/privacy")
public String privacy(){
return "privacy data...";
}
}
启动测试:
先从授权服务器获取token,携带token访问资源服务器:
携带令牌访问受保护资源:
curl -H "Authorization:Bearer 8eb06f4d-8361-46a6-87e4-f9dba8d6df1a" http://localhost:8081/privacy
Jwt实现
数据库实现需要存储token、authentication等大量数据,使用Jwt不会占用空间
授权服务器
MyJwtAuthorizationServer配置类:
@Configuration
@EnableAuthorizationServer
public class MyJwtAuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private AuthenticationManager authenticationManager;
@Bean
public ClientDetailsService jdbcClientDetailsService(){
JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);
jdbcClientDetailsService.setPasswordEncoder(new BCryptPasswordEncoder());
return jdbcClientDetailsService;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(jdbcClientDetailsService());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("secret"); //设置密钥
return converter;
}
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore())
.accessTokenConverter(jwtAccessTokenConverter())
.authenticationManager(authenticationManager);
}
}
资源服务器
MyJwtResourceServer配置类:
@Configuration
@EnableResourceServer
public class MyJwtResourceServer extends ResourceServerConfigurerAdapter {
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("secret"); //配置相同的密钥
return converter;
}
@Bean
public TokenStore tokenStore(){
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(tokenStore());
}
}
启动测试:
获取令牌:
携带令牌访问受保护资源:
解析JWT:
官网:https://jwt.io/
不会存储相关数据