单点登录SSO

1.什么是单点登录?

  • 单点登录(Single Sign On),简称为 SSO
    SSO是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分
  • 相比于单系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。

2.如何实现单点登录

  • 我的想法是,创建一个用户微服务,所有的用户认证都在这里进行处理,类似上面说的认证中心
    image
  • 服务端保存用户信息VS客户端保存用户信息
    image
  • cookie默认是当前域名有效,所以需要扩大cookie作用域
    image
  • 流程如下
    image

3.核心代码

  • 先整理一下思路流程
    image
@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
        implements UserInfoService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 登录
     *
     * @param userInfo
     * @return
     */
    @Override
    public LoginUserVo login(UserInfo userInfo) {
        // 构造查询条件
        LambdaQueryWrapper<UserInfo> wrapper = Wrappers.<UserInfo>lambdaQuery();
        wrapper.eq(UserInfo::getLoginName, userInfo.getLoginName())
                .eq(UserInfo::getPasswd, MD5.encrypt(userInfo.getPasswd()));
        UserInfo one = baseMapper.selectOne(wrapper);
        if (one == null) {
            // 账号或密码错误
            return null;
        }
        // 构建返回前端对象
        LoginUserVo loginUserVo = new LoginUserVo();

        // 将用户信息保存到redis中,生成随机令牌
        String token = saveUserAuthenticationInfo(one);
        // 昵称
        loginUserVo.setNickName(one.getNickName());
        // 令牌
        loginUserVo.setToken(token);

        return loginUserVo;
    }

    /**
     * 保存用户认证信息
     *
     * @param userInfo
     * @return
     */
    @Override
    public String saveUserAuthenticationInfo(UserInfo userInfo) {
        String token = UUID.randomUUID().toString().replace("-", "");
        redisTemplate.opsForValue().set(RedisConstants.USER_LOGIN_PREFIX + token, JsonUtils.toStr(userInfo), 7, TimeUnit.DAYS);
        return token;
    }

    /**
     * 退出登录
     *
     * @param token
     */
    @Override
    public void logout(String token) {
        // 从redis中删除,用户信息
        removeUserAuthenticationInfo(token);
    }

    private void removeUserAuthenticationInfo(String token) {
        redisTemplate.delete(RedisConstants.USER_LOGIN_PREFIX + token);
    }
}

4.现在有一个问题,当我们把用户信息存储在Redis中了,也就是以后每一个需要验证的自愿请求都需要与Redis建立连接查询数据,这有点浪费系统资源了!如何解决呢?

  • 解决方案:因为我的所有资源访问都是需要经过网关的,所以我直接在网关层根据请求头的token,去Redis查询,然后校验.成功则将用户详细信息透传到后面的微服务,不成功直接打回前端
  • Gateway+数据透传
核心代码:
package com.qbb.qmall.gateway.filter;

import com.fasterxml.jackson.core.type.TypeReference;
import com.qbb.qmall.common.constants.RedisConstants;
import com.qbb.qmall.common.result.Result;
import com.qbb.qmall.common.result.ResultCodeEnum;
import com.qbb.qmall.common.util.IpUtil;
import com.qbb.qmall.common.util.JsonUtils;
import com.qbb.qmall.gateway.properties.AuthProperties;
import com.qbb.qmall.model.user.UserInfo;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;

/**
 * @author QiuQiu&LL (个人博客:https://www.cnblogs.com/qbbit)
 * @version 1.0
 * @date 2022-06-03  10:51
 * @Description:
 */
@Slf4j
@Component
public class UserAuthFilter implements GlobalFilter {
    public static void main(String[] args) throws InterruptedException {
        // Mono<Integer> mono = Mono.just(1);

        // Flux<Integer> just = Flux.just(1, 2, 3, 4, 5);

        Flux<Long> just = Flux.interval(Duration.ofMillis(3000));

        // mono.subscribe((data) -> {
        //     System.out.println("A"+data);
        // });
        // mono.subscribe((data) -> {
        //     System.out.println("A"+data);
        // });
        // mono.subscribe((data) -> {
        //     System.out.println("A"+data);
        // });

        just.subscribe(System.out::println);
        just.subscribe(System.out::println);
        just.subscribe(System.out::println);

        Thread.sleep(100000);
    }


    @Autowired
    private AuthProperties authProperties;

    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 请求目标服务前会先经过过滤器
     *
     * @param exchange 封装了原来的请求和响应
     * @param chain    过滤器链
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取请求响应对象
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        String path = request.getURI().getPath();
        log.info("UserAuthFilter 开始拦截,请求路径:{}", request.getURI());

        // 1.获取配置的所有静态资源路径规则
        List<String> anyoneurls = authProperties.getAnyoneurls();
        for (String anyoneurl : anyoneurls) {
            // 校验路径规则
            boolean match = antPathMatcher.match(anyoneurl, path);
            if (match) {
                // 匹配成功,直接放行
                return chain.filter(exchange);
            }
        }

        // 2.内部路径不允许外部访问
        List<String> denyurls = authProperties.getDenyurls();
        for (String denyurl : denyurls) {
            boolean match = antPathMatcher.match(denyurl, path);
            if (match) {
                // 响应数据
                Result<String> result = Result.build("", ResultCodeEnum.FORBIDDEN);
                String toStr = JsonUtils.toStr(result);
                DataBuffer buffer = response
                        .bufferFactory().wrap(toStr.getBytes(StandardCharsets.UTF_8));
                // 发布数据
                Publisher body = Mono.just(buffer);
                // 防止乱码
                response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
                // 响应数据
                return response.writeWith(body);
            }
        }

        // 3.需要登录才能访问的资源
        List<String> authurls = authProperties.getAuthurls();
        for (String authurl : authurls) {
            boolean match = antPathMatcher.match(authurl, path);
            if (match) {
                // 已登录
                // 校验失败,没带token或者token被篡改
                boolean check = validateToken(request);
                if (!check) {
                    return redirectLoginPage(request, response);
                }
            }
        }

        // 其他正常请求
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            // 没带token直接放行
            return chain.filter(exchange);
        } else {
            // 携带了token必须是正确的
            boolean validateToken = validateToken(request);
            if (!validateToken) {
                // 错的
                return redirectLoginPage(request, response);
            } else {
                // 用户ID透传
                // 正确的
                UserInfo userInfo = getTokenRedisValue(token, IpUtil.getGatwayIpAddress(request));
                ServerHttpRequest newRequest = exchange.getRequest().mutate().header("UserId", userInfo.getId().toString()).build();

               /*旧写法,会报错
                request.getHeaders().add("UserId", userInfo.getId().toString());
                return chain.filter(exchange);*/
                ServerWebExchange newExchange = exchange
                        .mutate()
                        .request(newRequest)
                        .response(response)
                        .build();
                return chain.filter(newExchange);
            }
        }
    }

    /**
     * 重定向到登录页
     *
     * @param request
     * @param response
     * @return
     */
    private Mono<Void> redirectLoginPage(ServerHttpRequest request, ServerHttpResponse response) {
        // token校验错误,跳转到登录页
        // 设置响应码为302
        response.setStatusCode(HttpStatus.FOUND);
        // 设置跳转路径
        String originUrl = request.getURI().toString();
        URI uri = URI.create(authProperties.getLoginPath() + "?originUrl=" + originUrl);
        response.getHeaders().setLocation(uri);
        // 删除浏览器的假token,防止出现重定向死循环,设置一个同名cookie,让其立即过期
        ResponseCookie cookie = ResponseCookie.from("token", "QIUQIU&LL")
                .maxAge(0L)
                .domain(".gmall.com")
                .build();
        response.addCookie(cookie);
        return response.setComplete();
    }

    /**
     * 校验token
     *
     * @param request
     * @return
     */
    private boolean validateToken(ServerHttpRequest request) {
        // 获取token
        String token = getToken(request);
        // 判断
        if (StringUtils.isEmpty(token)) {
            // 为空
            return false;
        } else {
            // 获取IP
            String ipAddress = IpUtil.getGatwayIpAddress(request);
            // 不为空,校验token是否合法
            UserInfo userInfo = getTokenRedisValue(token, ipAddress);
            if (userInfo == null) {
                return false;
            }
            return true;
        }
    }

    /**
     * 根据token获取redis中的值
     *
     * @param token
     * @param ipAddress
     * @return
     */
    private UserInfo getTokenRedisValue(String token, String ipAddress) {
        // 根据前端的token去redis中获取用户信息
        String json = redisTemplate.opsForValue()
                .get(RedisConstants.USER_LOGIN_PREFIX + token);
        if (StringUtils.isEmpty(json)) {
            return null;
        }
        // 把json转为UserInfo对象
        UserInfo userInfo = JsonUtils.strToObj(json, new TypeReference<UserInfo>() {
        });
        // 校验此次登录的token的IP和之前登录的IP是否一致
        if (!userInfo.getIpAddress().equals(ipAddress)) {
            // 不一致
            return null;
        }
        return userInfo;
    }


    /**
     * 获取token
     *
     * @param request
     * @return
     */
    private String getToken(ServerHttpRequest request) {
        String token = "";
        // token可能在cookie中也可能在header中
        HttpCookie cookie = request.getCookies().getFirst("token");
        if (cookie != null) {
            token = cookie.getValue();
        } else {
            // 从请求头中取
            token = request.getHeaders().getFirst("token");
        }
        // 返回
        return token;
    }
}
app:
  auth:
    anyoneurls: # 放行静态资源
      - /js/**
      - /css/**
      - /img/**
      - /static/**
    denyurls: # 内部资源外部无法访问
      - /rpc/inner/**
    authurls: # 需要登录才能访问的资源
      - /order.html
    loginPath: http://passport.gmall.com/login.html
posted @   我也有梦想呀  阅读(62)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
点击右上角即可分享
微信分享提示