原理
0.操作
1.security的流程
(1)认证流程
具体过程:
1.需要实现UserDetailsService,自定义loadUserByUsername
查询数据库,得到权限,封装成UserDetails对象返回
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Resource
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
// 如果没有查询到用户就抛出异常
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// TODO 查询对应的权限信息
// 写死测试固定权限
// List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
List<String> list = menuMapper.selectPermsByUserId(user.getId());
// 把数据封装成 UserDetails 返回,参数:用户信息、权限列表
return new LoginUser(user, list);
}
}
2.实现登录操作
1.把传进来的用户名和密码封装成Authentication对象
2.利用AuthenticationManager的authenticate方法进行账号密码的校验
3。得到userId,作为JWT的内容
4.用户信息存储在redis
5.token返回给前端
/**
* 登录
*
* @param user
* @return
*/
@Override
public ResponseResult login(User user) {
// AuthenticationManager的authenticate方法 进行用户认证
// 拿到用户输入的用户名和密码,封装成 Authentication 对象
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
// 让 authenticationManager 拿着信息帮助我们去进行认证操作,查数据库校验
//要使用authenticationManager 是通过重写,然后注入,就可以在这直接用
//返回的是一个完全认证的对象
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 如果认证没通过,给出对应的提示
if (Objects.isNull(authenticate)) {
throw new RuntimeException("用户名或密码错误");
}
// 认证通过,使用 userId 生成一个 jwt ,jwt 存入
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
// 使用用户ID生成 jwt,解析 jwt 结果为 用户 id
String jwt = JwtUtil.createJWT(userId);
Map<String, String> map = new HashMap<>(1);
map.put("token", jwt);
// 把完整用户信息存入 redis ,userId 作为 key
//用户存到redis是为了减少服务器压力,之后从redis得到用户信息
redisCache.setCacheObject("login:" + userId, loginUser);
// 登录成功,把token返回
return new ResponseResult(200, "登录成功", map);
}
3.JWT过滤器
/**
* 认证过滤器
* @author bing_ @create 2022/1/5-14:12
* 继承 OncePerRequestFilter 保证请求经过过滤器一次
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取 token ( 前端,用户登录后,将 token 放到请求头当中。所以这里从请求头中获取 token )
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
// 如果请求头没有 token ,放行,就不需要后面的操作。之后的过滤器就会拦截
filterChain.doFilter(request, response);
//必须要return,因为等请求转回来之后,不return还会执行下面的对token的操作
return;
}
// token 不为空,解析 token
String uesrId;
try {
Claims claims = JwtUtil.parseJWT(token);
uesrId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("非法 token");
}
// 从 redis 中获取用户信息
String redisKey = "login:" + uesrId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
// 将用户信息存入 SecurityContextHolder
// 获取权限信息封装到 Authentication 中
// 参数:用户信息、已认证状态、权限信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
3.退出登录
从SecurityContextHolder拿到登录用户id
从redis删除
public ResponseResult logout() {
// 获取 SecurityContextHolder 中的用户 id
UsernamePasswordAuthenticationToken authentication =
(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId = loginUser.getUser().getId();
// 删除 redis 中的值
redisCache.deleteObject("login:" + userId);
return new ResponseResult(200, "退出成功");
}
一 基础登录和自定义登录问题
1.不做任何配置
引入了依赖之后,就会自动生效,会自动跳转的自带的登录页面
账号:user 密码:控制台输出(随机生成)
2.自定义登录逻辑
(1)UserDetailsService——通过用户名得到数据库数据
1.会加载用户名,把信息从数据库中取出来,返回一个UserDetails
2.会进行比较用户名和密码
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
2.UserDetails是一个接口
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
3.通过构造方法将数据形成User对象,然后交给框架去验证
(2)PasswordEncoder——密码解析器
官方推荐的实现类是BCryptPasswordEncoder,这是一个强散列算法,单向加密(因为每次生成不同的salt)
1.encode会把明文密码加密
2.matches把明文密码和加密好的密文比较,看是否一致
3.upgradeEncoding,对密文进行二次加密(一般不用)
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
(3)开始自定义登录逻辑
1.因为要用到PasswordEncoder,但是不允许通过new它的实体类BCryptPasswordEncoder来使用它,所以,写一个配置文件,直接返回BCryptPasswordEncoder实例,然后在用的时候引入
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder pw() {
return new BCryptPasswordEncoder();
}
}
2,实现自定义登录逻辑
@Service
//实现UserDetailsService从而实现自定义登录逻辑
public class UserServiceImpl implements UserDetailsService {
@Resource
//这个时候引入的就是配置文件中写的BCryptPasswordEncoder实例
private PasswordEncoder pw;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("不可以用username从数据库中查出来名字"){
throw new UsernameNotFoundException("用户名或密码错误");
}
if (!pw.matches("传进来的密码", "数据库的密码")) {
throw new UsernameNotFoundException("用户名或密码错误");
}
//返回给前端UserDetails的实现类User
return new User(username, "密文密码", AuthorityUtils.commaSeparatedStringToAuthorityList("权限1, 权限2"));
}
}
3自定义登录页面
因为现在登录还是被拦截到自带的登录页面
1.实现WebSecurityConfigurerAdapter 完成配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder pw() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//表单登录
http.formLogin()
//子定义登录页面
.loginPage("/login.html")
//自定义登录逻辑
//表单的/login对应的不是接口的login
//表单的/login是要去找自定义逻辑方法,所以在这配置login意思
//就是把表单的登录和自定义逻辑方法连接起来
.loginProcessingUrl("/login")
//登录成功后跳转的页面
// 这种方式不行,因为这种属于GET请求,必须是POST
// .successForwardUrl("main.html");
.successForwardUrl("/toMain")
.failureForwardUrl("/toFail");
//关闭csrf
http.csrf().disable();
http.authorizeRequests()
//放行登录页面,如果不放行,就会全部拦截,不停的重定向
.antMatchers("/login.html").permitAll()
.antMatchers("/fail.html").permitAll()
//拦截所有页面,所有页面都要登录
.anyRequest().authenticated();
}
}
4.自定义表单name属性
UsernamePasswordAuthenticationFilter
里面规定了前台表单必须name是username和password,方法是post
也可再配置文件中修改为自定义的name
http.formLogin()
.usernameParameter("自定义的表单的username")
.passwordParameter("自定义的表单的password");
5.自定义成功跳转
跳转默认只能使用.successForwardUrl(“/toMain”)这种POST的方式
原理:
- 进入successForwardUrl
public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
this.successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
return this;
}
- 进入ForwardAuthenticationSuccessHandler
public class ForwardAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final String forwardUrl;
public ForwardAuthenticationSuccessHandler(String forwardUrl) {
Assert.isTrue(UrlUtils.isValidRedirectUrl(forwardUrl), () -> {
return "'" + forwardUrl + "' is not a valid forward URL";
});
this.forwardUrl = forwardUrl;
}
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
request.getRequestDispatcher(this.forwardUrl).forward(request, response);
}
}
可以看到这是一个AuthenticationSuccessHandler 接口的实现方法
构造函数中对于url进行是否valid的校验
然后onAuthenticationSuccess方法中进行了请求转发,所以必须是POST
那么如何进行GET方式的请求?
- 写一个配置类,同样实现AuthenticationSuccessHandler ,然后再onAuthenticationSuccess方法中使用重定向
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final String url;
public MyAuthenticationSuccessHandler(String url) {
this.url = url;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//如果是先后端分离,这就返回JSON
response.sendRedirect(url);
}
}
- 然后调用这个自己写的实现类,传入GET请求
.successHandler(new MyAuthenticationSuccessHandler("http://baidu.com"))
6.自定义失败跳转
和成功一样,就是实现的接口不同,使用时的Handler不同
二 授权问题
1.访问控制url匹配
http.authorizeRequests()
是对url进行访问控制
常用的访问控制方法如下:
- anyRequest():表示匹配所有请求。
.anyRequest().authenticated();
表示对所有请求要认证。一定要放在最后,不然后面的不生效 - antMatcher()
public C antMatchers(String... antPatterns)
参数是一个不定项参数,每一个参数都是一个ant表达式,用于匹配URL
匹配规则如下:
1.?:匹配一个字符
2.*:匹配0或多个字符
3.**:匹配0或多个目录
public abstract C mvcMatchers(HttpMethod method, String... mvcPatterns);
还可以定义请求类型
-
regexMatchers()
和antMatchers的区别就是正则表达式进行匹配 -
mvcMatchers()
.mvcMatchers("/image/**").servletPath("/xxx")
相当于
.antMatchers("/xxx/image/**").permitAll()
2.角色权限判断
所有的访问权限控制如下:
//放行所有
static final String permitAll = "permitAll";
//拒绝所有
private static final String denyAll = "denyAll";
//匿名访问时允许通行
private static final String anonymous = "anonymous";
//认证之后放行
private static final String authenticated = "authenticated";
//不允许记住的访问,必须完整登录才可以访问
private static final String fullyAuthenticated = "fullyAuthenticated";
//记住用户后可以放行
private static final String rememberMe = "rememberMe";
除了上面的几种,还有一些:
1.根据权限匹配
//必须有这个权限才可(这个权限严格区分大小写)
.antMatchers("/fail.html").hasAnyAuthority("权限")
//有以下多个权限的一个就可访问
.antMatchers("/fail.html").hasAnyAuthority("1","2")
2.根据角色匹配
.antMatchers("/fail.html").hasRole("abc")
.antMatchers("/fail.html").hasAnyRole("abc","a")
注意:在返回给前端的User中,角色必须以ROLE_开头,但是在匹配中,不能加前缀,因为会自动补上,若加上就会报错
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (!"admin".equals(username)){
throw new UsernameNotFoundException("用户名或密码错误");
}
String encode = pw.encode("123456");
//返回UserDetails的实现类User
return new User(username,encode , AuthorityUtils.commaSeparatedStringToAuthorityList("权限1, 权限2,ROLE_abc"));
}
}
3.限制ip地址进行访问
.antMatchers("/fail.html").hasIpAddress("127.0.0.1")
3.自定义403页面
在访问授权出问题就会403页面,如何自定义403页面?
1.实现处理拒绝访问类,实现自定义的拒绝访问处理
@Component
public class MyaccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;cahrset=utf-8");
PrintWriter writer = response.getWriter();
writer.write("{\n" +
" \"status\": 10000,\n" +
" \"msg\": \"权限不足\",\n" +
" \"data\": {\n" +
" }\n" +
"}");
writer.flush();
writer.close();
}
}
2.传入自定义拒绝访问对象,进行自定义异常处理
//注入403自定义处理
@Resource
private MyaccessDeniedHandler myaccessDeniedHandler;
//异常处理
http.exceptionHandling()
.accessDeniedHandler(myaccessDeniedHandler);
4.access自定义方法
以上的权限判断都是基于access表达式
public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry hasRole(String role) {
return this.access(ExpressionUrlAuthorizationConfigurer.hasRole(ExpressionUrlAuthorizationConfigurer.this.rolePrefix, role));
}
public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry hasAnyRole(String... roles) {
return this.access(ExpressionUrlAuthorizationConfigurer.hasAnyRole(ExpressionUrlAuthorizationConfigurer.this.rolePrefix, roles));
}
所以 .antMatchers("/login.html").permitAll()
.antMatchers("/login.html").access("permitAll()")
这两句话是等价的
所以可以通过access自定义权限控制逻辑
以下实例:如果当前用户有访问main.html的权限,就可以访问,不然不行
1.自定义接口和实现类,实现自定义权限逻辑
public interface MyAuthService {
/**
*
* @param request 得到请求
* @param authentication 包含用户信息
* @return
*/
boolean hasMainAuth(HttpServletRequest request, Authentication authentication);
}
@Service
public class MyAuthServiceImpl implements MyAuthService{
@Override
public boolean hasMainAuth(HttpServletRequest request, Authentication authentication) {
String requestURI = request.getRequestURI();
Object principal = authentication.getPrincipal();//得到用户对象
if (principal instanceof UserDetails) {
//获得权限
Collection<? extends GrantedAuthority> authorities =
((UserDetails) principal).getAuthorities();
//权限集合的泛型是GrantedAuthority接口,实现类为SimpleGrantedAuthority
return authorities.contains(new SimpleGrantedAuthority(requestURI));
}
return false;
}
}
2.使用自定义access逻辑
.anyRequest().access("@MyAuthServiceImpl.hasMainAuth(request,authentication)");
5.基于注解的访问控制
1.注解需要开启@EnableGlobalMethodSecurity
2.注解可以写在service接口或者方法上,也可写在controller或其方法上,通常写在控制器方法上
- @Secured
判断是否具有角色,参数要以ROLE_开头
相当于.antMatchers("/fail.html").hasRole("abc")
配置文件中的hasRole不能以ROLE_kait
而注解方式必须以它开头
都区分大小写
@EnableGlobalMethodSecurity(securedEnabled = true)开启@Secured注解
@PostMapping("/toMain")
@Secured(("ROLE_abc"))
public String toMain() {
return "redirect:main.html";
}
- @PreAuthorize/@PostAuthorize
两个都是判断权限的,一个是在方法之前判断,一个是方法结束之后判断
1.@EnableGlobalMethodSecurity(prePostEnabled = true)开启@PreAuthorize/@PostAuthorize
2.@PreAuthorize中是access表达式,在里面判断角色,可以加ROLE_也可不加
@PostMapping("/toMain")
@Secured(("ROLE_abc"))
@PreAuthorize("hasAnyRole('ROLE_abc')")
public String toMain() {
return "redirect:main.html";
}
@PostMapping("toFail")
@PreAuthorize("hasAnyRole('abc')")
public String toFail() {
return "redirect:fail.html";
}
6.RememberMe的实现
会自动把用户信息存储在数据库中
(1)添加依赖
RememberMe的实现依赖于Spring-JDBC
所以需要导入mybatis和mysql
的依赖
(2)实现
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//引入自定义登录逻辑
@Resource
private UserServiceImpl userService;
//引入配置文件中写好的数据源
@Resource
private DataSource dataSource;
//引入写好的存储逻辑PersistentTokenRepository
@Resource
private PersistentTokenRepository tokenRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
//实现记住用户
http.rememberMe()
//前端记住我的复选框参数本来必须是name="remember-me",可以自定义
// .rememberMeParameter()
//失效时间,默认;两周(秒数)
// .tokenValiditySeconds()
//自定义记住我的逻辑
// .rememberMeServices()
//自定义登录逻辑
.userDetailsService(userService)
//指定存储位置(这里的参数类型为PersistentTokenRepository接口)
.tokenRepository(tokenRepository);
//实现PersistentTokenRepository存储逻辑
@Bean
public PersistentTokenRepository tokenRepository() {
/**
* PersistentTokenRepository是个接口,实现类有两种
* 1.InMemoryTokenRepositoryImpl 存储在内存
* 2.JdbcTokenRepositoryImpl 存储在数据库
*/
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
//设置数据源
jdbcTokenRepository.setDataSource(dataSource);
//启动时是否创建表,第一次要,之后注释掉
jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
}
7.退出登录
1.默认
直接写推出标签,然后href赋值的是security的/logout,点击就直接实现了推出登录
<a href="/logout">退出登录</a>
2.用的到的配置
//退出登录
http.logout()
//退出登录的URL,也就是<a href="/logout">退出登录</a>
.logoutUrl("/logout")
//退出成功跳转的页面
.logoutSuccessUrl("/login.html");
8.CSRF
需要前端传过来token,会自动在后端进行比较,若成功就可以访问
三 Oauth2
四 JWT
1.JWT的结构
2.使用
- 引入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
- 获取 token
public final static long EXPIRE_TIME=30*60*1000;//放在类中 ,30分钟的毫秒数
//获取时间,用来做token的过期时间,设置30分钟
Date date=new Date(System.currentTimeMillis()+EXPIRE_TIME)
Map<String,Object> map=new HashMap<>();
String sign = JWT.create().withHeader(map) //header 这个不设置也可以
.withClaim("userId", 12)//payload 存储非敏感的信息 例如用户账号,不能存密码,防止被人解析
.withExpiresAt(date)//指定令牌的过期时间
.sign(Algorithm.HMAC256("!we2123")) ;//签名 保密复杂
System.out.println(sign); //输出结果
- 验证token
//创建验证对象
//验证时一定要跟生成时的 算法 和 签名 一致
JWTVerifier build = JWT.require(Algorithm.HMAC256("!we2123")).build();
//得到一个解码后的对象
DecodedJWT verify = build.verify(" 这里放token ");
/**
以上两行代码就已经完成验证了,如果没有出异常,就验证成功!
出现了就是验证失败 ,前提要保证算法一定要一致,不要出现算法不一致异常
*/
//从解码后的对象获取payload存储的信息
System.out.println(verify.getClaim("userId").asInt()); //存什么类型 就as什么类型,否则为null
System.out.println(verify.getClaim("username").asString());
Date expiresAt = verify.getExpiresAt(); // 查看token过期时间
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String format = simpleDateFormat.format(expiresAt);
System.out.println(format);
- 工具类
public class JWTUtil {
//30分钟
public final static long EXPIRE_TIME=30*60*1000;
/**
* 生成token header.payload.sing
*/
public static String getToken(Map<String, String> map){
Date date=new Date(System.currentTimeMillis()+EXPIRE_TIME); //默认30分钟过期
//创建JWT builder
JWTCreator.Builder builder = JWT.create();
// payload
map.forEach((k,v)->{
builder.withClaim(k,v); //这里可以存放 用户id,用户名
});
//指定过期时间,sign ,生成token 这里的签名是指定好的
//实际项目中可以 接收用户的密码来做签名,这样每一个用户对应一个签名
java.lang.String token = builder.withExpiresAt(date).sign(Algorithm.HMAC256("1234"));
return token;
}
/**
* 验证token合法性
*/
public static boolean verify(String token){
try {
//如果抛出异常,证明签名不一致 / token过期
JWT.require(Algorithm.HMAC256("1234")).build().verify(token);
return true;
}catch (Exception e){
return false;
}
}
/**
* 获得token中的信息无需secret解密也能获得
*/
public static String getUserName(String token){
DecodedJWT decode = JWT.decode(token);
//假设存储的是一个phone
String phone = decode.getClaim("phone").asString();
return phone;
}
3. jwt验证出现的异常
验证时会先验证签名,再验证令牌有没有过期
# 签名验证异常 (生成时和验证时的签名不一致,会出现此异常)
- SignatureVerificationException
# 算法不匹配异常 (生成时和验证时的算法不一致,会出现此异常)
- AlgorithmMismatchException
# 令牌过期异常 (生成时设置的时间超时后,再次验证会出现此异常)
- TokenExpiredException
# 失效的payload异常 (出现此异常的原因有:可能有人使用base64解析payload,更改了数据payload里解析的数据,再次传过来,验证时会出现此异常)
- InvalidClaimException