基于Oauth2实现单点登录

基于Oauth2实现单点登录

前言

前一段时间,突发奇想,趁着工作之余。从0开始搭建一套vue+springcloud的个人半成品作品,而其中使用到了sso,以此记录.
单点登录

一、SSO是什么?

简单来说就是在多个系统中,用户只需登录一次,各个系统即可感知用户已经登录,一般包括登录与注销两部分.其架构图如下所示
sso

二、jwt

数据结构

它主要由三部分组成,分别是Header、Payload、Signature,其JWT的官网地址是:https://jwt.io/
jwt组成部分
三部分数据用’.'隔开

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

1. Header
Header通常由两部分组成,例如

{
“typ”: “JWT”,
“alg”: “HS256”
}

“typ”: "JWT"令牌类型,即JWT。
“alg”: "HS256"使用的签名算法。

2. Payload
Payload是用来存放实际需要保存数据的地方,其中JWT官方也定义了一些字段

{
“sub”: “1234567890”,
“name”: “John Doe”,
“iat”: 1516239022
}

如笔者系统中会将用户名,头像地址等非敏感信息放入该结构中

3. Signature

Signature是对前面两部分数据的签名,用于验证数据没有被篡改

OAuth2.0

它是基于令牌机制,即访问服务器,通过一些列机制保证请求合法后,颁发客户端令牌,最后客户端通过该令牌进行访问,从而保护被访问资源
官网原理图
引用官网的流程图(如上所示),
首先需要了解抽象出的四个角色,这里不妨以登陆微交流学习平台为例对上述四种角色具体化
client(客户端)-微交流学习平台(第三方平台)
Resource Owner - 登陆用户
Authorization server -github开放平台
Resource Server - github用户相应信息的api接口

授权模式

  • 授权码模式
  • 简化模式
  • 密码模式
  • 客户端模式

而本次使用的是密码模式
用户向客户端提供自己的用户名,密码从而直接获取到token令牌,然后通过令牌直接访问受保护资源


1 通过用户名,密码获取token

http://localhost:8888/oauth/token?client_id=git&client_secret=secret&username=test&password=123&grant_type=password&scope=all

2 通过token获取资源

http://localhost:8888/api/userinfo?access_token=b86b03de-6edc-4a69-a99c-8cccd21d1730

码上有戏

有了前面的理论基础,不妨来实操一把
项目结构
如上sso-auth为统一授权中心,sso-portal为客户端封装鉴权的模块,实际上一般传统老后管项目,都有一个portal系统,子业务系统通过引入其jar进行登录管理。而相应的公告服务都在portal里进行处理。 这里只作为一个jar。
sso-client1、sso-client2为业务系统

sso-auth

首先为jwt增强器,我们一些业务数据如用户名、头像等都可以放入增强器中
JwtTokenEnhancer

@Component
public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
        Map<String, Object> info = new HashMap();
        //把用户ID设置到JWT中
        info.put("userId", securityUser.getId());
        info.put("userName", securityUser.getUsername());
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}

认证服务器配置

@AllArgsConstructor
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

    private final PasswordEncoder passwordEncoder;
    private final UserServiceImpl userDetailsService;
    private final AuthenticationManager authenticationManager;
    private final JwtTokenEnhancer jwtTokenEnhancer;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                //标记客户端id
                .withClient("client-app")
                //客户端安全码
                .secret(passwordEncoder.encode("123456"))
                //允许授权范围
                .scopes("all")
                //允许授权类型
                .authorizedGrantTypes("password", "refresh_token", "logout")
                //token 时间秒
                .accessTokenValiditySeconds(3600*24*7)
                //刷新token 时间 秒
                .refreshTokenValiditySeconds(864000*24*3);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> delegates = new ArrayList();
        delegates.add(jwtTokenEnhancer);
        delegates.add(accessTokenConverter());
        enhancerChain.setTokenEnhancers(delegates); //配置JWT的内容增强器
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService) //配置加载用户信息的服务
                .accessTokenConverter(accessTokenConverter())
                .tokenEnhancer(enhancerChain);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setKeyPair(keyPair());
        return jwtAccessTokenConverter;
    }

    @Bean
    public KeyPair keyPair() {
        //从classpath下的证书中获取秘钥对
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
    }
}

其中PasswordEncoder为sercurity匹配密码模式,这里为了方便,直接读取密码(实际工作中还是用多次加密算法匹配的)

SpringSecurity配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
                    .antMatchers("/rsa/publicKey").permitAll()
                    .antMatchers("/oauth/logout").permitAll()
                    .antMatchers("/oauth/verify").permitAll()
                    .anyRequest().authenticated()
                    .and()
                    .csrf().disable();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

}

获取RSA公钥接口

@RestController
public class KeyPairController {
    @Autowired
    private KeyPair keyPair;

    @GetMapping("/rsa/publicKey")
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

sso-portal

配置策略
ResourceWebSecurityConfig

@Configuration
@EnableWebSecurity
public class ResourceWebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
    private String jwkSetUri;
    @Autowired
    private MyAccessDeniedHandler myAccessDeniedHandler;
    @Autowired
    private MyAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated().and()
                .sessionManagement().disable()
                .oauth2ResourceServer().accessDeniedHandler(myAccessDeniedHandler)
                .authenticationEntryPoint(myAuthenticationEntryPoint)
                .jwt().jwkSetUri(jwkSetUri);
    }
}

如果请求不合法,直接被MyAccessDeniedHandler或MyAuthenticationEntryPoint直接拦截

配置拦截器
OauthFilter

@Component
public class OauthFilter implements Filter {

    @Resource
    private IgnoreUrlsConfig ignoreUrlsConfig;

    private static final String verifyUrl = "http://localhost:8080/oauth/verify";

    //0:不走redis验证  1:redis验证
    @Value("${redis-token-flag}")
    private int redisTokenFlag;

    private static final long RENEW_DURATION = 3600 * 24;    //second

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;

        boolean isWhitelist = false;
        String[] ignoreUrls = ignoreUrlsConfig.getUrls().toArray(new String[]{});
        List<RequestMatcher> requestMatcherList = RequestMatchers.antMatchers(ignoreUrls);
        for (RequestMatcher matcher : requestMatcherList) {
            if (matcher.matches(httpRequest)) {
                isWhitelist = true;
                break;
            }
        }

        if (isWhitelist) {
            chain.doFilter(request, response);
        } else {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            //对存在token的数据,做用户信息提取
            if (authentication.isAuthenticated()) {
                //提取用户的身份信息
                Jwt jwt = (Jwt) authentication.getPrincipal();
                String userId = jwt.getClaim("userId");
                String userName = jwt.getClaim("userName");
                //redis验证用户缓存是否存在
                if (redisTokenFlag == 1) {
                    //远程sso调用
                    String tokenMd5 = "";
                    String authorization = getToken(httpRequest);
                    if (StringUtils.isNotEmpty(authorization)) {
                        String postResult = HttpRequest
                                .post(verifyUrl)
                                .header("Authorization", authorization)
                                .body(authorization)
                                .execute()
                                .body();
                        Result result = JSON.parseObject(postResult, Result.class);
                        Object json = result.getData();
                        tokenMd5 = (String) json;
                    }
                    if (!StringUtils.isEmpty(tokenMd5)) {
                        //token不同:说明该用户redis上已有最新的token,该账号已在其他地方登录,当前的token已失效
                        if (!tokenMd5.equals(DigestUtils.md5DigestAsHex(jwt.getTokenValue().getBytes()))) {
                            response.setContentType("application/json");
                            response.getWriter().println("{\"code\":96,\"message\":\"Token " +
                                    "Invalid\"," +
                                    "\"data\":\"\"}");
                            response.getWriter().flush();
                            return;
                        }
                    } else {
                        response.setContentType("application/json");
                        response.getWriter().println("{\"code\":99,\"message\":\"redis " +
                                "Authorization failed\"," +
                                "\"data\":\"\"}");
                        response.getWriter().flush();
                        return;
                    }
                }
                //检查续期,token接近有效期时自动续期
                if ((jwt.getExpiresAt().getEpochSecond() - System.currentTimeMillis() / 1000L) < RENEW_DURATION) {
                    response.setContentType("application/json");
                    response.getWriter().println("{\"code\":98,\"message\":\"Token Renew\"," +
                            "\"data\":\"\"}");
                    response.getWriter().flush();
                    return;
                }

                UserInfo user = new UserInfo();
                user.setUserId(userId);
                user.setUserName(userName);
                UserContextHolder.setUser(user);

            }
            try {
                chain.doFilter(request, response);
            } finally {
                UserContextHolder.removeUser();
            }
        }
    }

    private String getToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (StringUtils.isEmpty(authorization)) {
            authorization = request.getParameter("accessToken");
        }
        return authorization;
    }

    /**
     * 引入matcher
     */
    private static class RequestMatchers {

        public static List<RequestMatcher> antMatchers(HttpMethod httpMethod,
                                                       String... antPatterns) {
            String method = httpMethod == null ? null : httpMethod.toString();
            List<RequestMatcher> matchers = new ArrayList<>();
            for (String pattern : antPatterns) {
                matchers.add(new AntPathRequestMatcher(pattern, method));
            }
            return matchers;
        }


        public static List<RequestMatcher> antMatchers(String... antPatterns) {
            return antMatchers(null, antPatterns);
        }


        public static List<RequestMatcher> regexMatchers(HttpMethod httpMethod,
                                                         String... regexPatterns) {
            String method = httpMethod == null ? null : httpMethod.toString();
            List<RequestMatcher> matchers = new ArrayList<>();
            for (String pattern : regexPatterns) {
                matchers.add(new RegexRequestMatcher(pattern, method));
            }
            return matchers;
        }


        public static List<RequestMatcher> regexMatchers(String... regexPatterns) {
            return regexMatchers(null, regexPatterns);
        }

        private RequestMatchers() {
        }
    }
}

该过滤器作用为

  • 1.redis验证,token是否已经主动注销
  • 2.提取参数放入线程本地变量

可以在此配置白名单

业务系统

用户需提前进行登录,获取token。
1、用户登录接口
POST /oauth/token?grant_type=password&client_id=client-app&client_secret=123456&username=test&password=123

登录
获取token后,直接拿token去访问业务接口
测试
同样可以使用单点注销

单点注销

特别说明

由于时间比较紧,demo写的有点粗糙,具体可在github上查看源码分析。
另外,目前比较流行的都是在gateway中进行鉴权替代portal方案。而笔者作品也是基于此摸索升级为gateway中进行控制,后期在详细写出。

结合个人作品

实际项目涉及场景比较多,目前简单列出笔者作品中的一些做法

前端
就是单一验证token,通过返回码进行合法性与是否token续期等控制

 if (store.getters.token) {
      config.headers['Authorization'] = `Bearer ${getToken()}`
    }
 if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
      // to re-login
      Message({
        type: 'error',
        message: '登录异常,请重新登录'
      })
      setTimeout(() => {
        store.dispatch('user/resetToken').then(() => {
          location.reload()
        })
      }, 2000)
      return res
    }
    if(res.code == 96){
      Message({
        message: "您的账号在另一处登录",
        type: 'error',
        duration: 5 * 1000
      })
      location.reload()
      return
    }

    if(res.code == 99){
      Message({
        message: res.message,
        type: 'error',
        duration: 5 * 1000
      })
      location.reload()
      return
    }

利用旁路缓存方式将权限放入redis,前端过滤器每次请求直接从缓存读取
在这里插入图片描述
扩展验证方式,走不通验证逻辑。客户端约定username不同方式登录前缀(如AD登录,第三方扫码登录等),走不通逻辑
在这里插入图片描述

总结

文章开头流程图说明
1、client登录,调用令牌获取接口、内部通过httpClient请求形式与oauth2的密码模式远程提交相应参数
2、Sso认证服务器,根据用户请求,验证其用户名密码、通过后使用jwt生成token
3、Biz业务服务,获取令牌返回给客户端,同时,将token与系统资源存入redis中
4、客户端保存令牌到cookie中,同时后续每次请求都携带令牌
5、Biz业务服务,每次都会进行请求过滤验证,通过jwt配合秘钥对令牌合法性、是否过期等进行验证
6、根据配置中是否在redis中进行令牌验证,并且需要校验时,一个用户只能有一个最新token
7、令牌验证通过后,返回客户端业务数据

源码

github

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spring Cloud OAuth2是一个基于OAuth2实现的身份认证和授权框架,可以用于实现单点登录(SSO)功能。 单点登录是指在一个系统中登录后,可以在其他关联系统中自动登录,无需再次输入账号和密码。使用Spring Cloud OAuth2可以实现这样的功能。 首先,需要在认证服务器上使用Spring Security和Spring Cloud OAuth2的组件搭建一个OAuth2认证服务。该服务会负责用户的认证和授权工作。 在各个子系统中,需要引入Spring Cloud OAuth2的客户端,然后配置认证服务器的地址和客户端的凭证信息(clientId和clientSecret)。 当用户访问某个子系统时,子系统会重定向到认证服务器进行认证。用户在认证服务器上输入账号和密码进行认证,认证成功后,认证服务器会返回一个授权码给子系统。 子系统将授权码发送给认证服务器,认证服务器通过校验授权码的有效性,并且根据授权码发放一个访问令牌。子系统使用访问令牌进行后续的接口访问。 当用户在其他关联系统中访问时,这些系统会共享认证服务器上的会话信息,无需再次进行登录认证,直接使用之前的访问令牌进行接口访问。 通过以上步骤,就实现Spring Cloud OAuth2的单点登录功能。用户只需要在一个系统登录一次,就可以在其他系统中自动登录,提高了用户体验。同时,认证服务器集中管理用户的认证和授权信息,提供了一种便捷的集中式身份管理方式。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值