前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >10分钟搞定OAuth2.0授权服务

10分钟搞定OAuth2.0授权服务

作者头像
林老师带你学编程
发布2020-09-11 14:28:49
6280
发布2020-09-11 14:28:49
举报
文章被收录于专栏:强仔仔强仔仔

现在授权模式基本都是用OAuth2.0,什么OAuth2.0呢?这个有兴趣的同学可看阮大神的文章,今天主要给大家介绍一下OAuth 2.0的一种实现。

OAuth 2.0 的一个简单解释http://www.ruanyifeng.com/blog/2019/04/oauth_design.html

OAuth 2.0的授权主要通过Token令牌验证,这就涉及到用什么框架来生成Token了。Java一般常用的有两类框架,一个是Shiro和Spring Security,他们俩之间的区别,有兴趣的同学可以自行了解,今天主要给大家分享的是Spring Security中JWT的用法。

一、什么是JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT是由三部分构成:

  • 第一部分我们称它为头部(header)
  • 第二部分我们称其为载荷(payload, 类似于飞机上承载的物品)
  • 第三部分是签证(signature).

header

jwt的头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256 完整的头部就像下面这样的JSON:
{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

playload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明(建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之前,该jwt都是不可用的.
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

公共的声明 :

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

私有的声明 :

私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到Jwt的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret 这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

二、案例demo

JWT的概念讲完了,接下来就给大家详细的介绍一下代码的具体实现,客户端和服务器调用的流程,可以参照下面过程:

引入JWT和Spring Security依赖

<!--springsecurity依赖-->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- jwt相关依赖 -->
<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.1</version>
</dependency>

添加Web配置文件,我们需要将除了登陆授权以外的接口,都进行过滤拦截,校验Token的合法性。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder
                // 设置UserDetailsService
                .userDetailsService(userDetailsService)
                // 使用BCrypt进行密码的hash
                .passwordEncoder(passwordEncoder());
    }

    // 装载BCrypt密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAuthenticationTokenFilter authenticationTokenFilterBean() throws Exception {
        return new JwtAuthenticationTokenFilter();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

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

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 由于使用的是JWT,我们这里不需要csrf
                .csrf().disable()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 允许对于网站静态资源的无授权访问
                .antMatchers(
                        HttpMethod.GET,
                        "/",
                        "/*.html",
                        "/favicon.ico",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                // 授权接口放通token校验
                .antMatchers("/authority/**/authorization/").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);
        // 禁用缓存
        httpSecurity.headers().cacheControl();
    }
}

Web配置文件中我们可以看到,还需要UserDetailsService和JwtAuthenticationTokenFilter。UserDetailsService是Spring Security内部接口,我们需要实现该接口的loadUserByUsername方法,将查询到username和password返回,具体代码如下所示:

@Slf4j
@Service
public class UserDetailServiceImpl implements UserDetailsService {
    @Autowired
    private TamadbUserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        TamadbUser userPo = userMapper.getUserBaseInfo(Integer.valueOf(userName));
        if (userPo == null) {
            log.error("loadUserByUsername--->userName:{}不存在", userName);
            throw new UsernameNotFoundException("用户名不存在");
        }
        SysUserPo user = new SysUserPo();
        user.setUsername(userPo.getId() + "");
        user.setPassword(userPo.getPassword());
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        final String rawPassword = user.getPassword();
        user.setPassword(encoder.encode(rawPassword));
        return user;
    }
}

userMapper.getUserBaseInfo方法就是一个dao,用来查询数据库的用户信息,因为WebSecurityConfig配置文件,对密码配置了BCryptPasswordEncoder加密,但是数据库存储的是md5生成的密码,所以我们需要对密码进行等价加密。

我们接着来看一下JwtAuthenticationTokenFilter过滤器的内容:

@Component
@Log4j2
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired(required = false)
    private UserDetailsService userDetailsService;

    @Value("${jwt.header}")
    private String header;

    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.header);
        if (authHeader != null && authHeader.startsWith(tokenHead) && authHeader.length() > tokenHead.length() + 1) {
            // The part after "Bearer "
            final String authToken = authHeader.substring(tokenHead.length() + 1);
            String username = jwtTokenUtil.getUsernameFromToken(authToken);
            log.info("checking authentication,username:{},authToken:{}", username, authToken);
            // 校验token是否有效合法
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                // 校验token是否过期
                if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(
                            request));
                    log.info("authenticated user " + username + ", setting security context");
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } else {
                    log.error("token过期,token:{}", authToken);
                }
            } else {
                log.error("token失效,无法获取到用户信息,token:{}", authHeader);
            }
        }
        chain.doFilter(request, response);
    }
}

过滤器就做一件事情,获取Http头部的Token信息,然后通过jwtTokenUtil解密Token,获取用户信息,最后检验Token是否过期。

我们最后来看看jwtTokenUtil工具类中,是如何生成、解密Token的。

@Component
@Log4j2
public class JwtTokenUtil implements Serializable {
    private static final long serialVersionUID = -3301605591108950415L;

    /**
     * 用户id
     */
    private static final String CLAIM_KEY_USERNAME = "sub";

    /**
     * 用户登录信息
     */
    private static final String AUTHORITY_USER_DETAIL = "detail";

    /**
     * token创建时间
     */
    private static final String CLAIM_KEY_CREATED = "created";

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration.pc.access}")
    private Long pcAccessExpiration;

    @Value("${jwt.expiration.pc.refresh}")
    private Long pcRefreshExpiration;

    @Value("${jwt.expiration.wechat.access}")
    private Long weChatAccessExpiration;

    @Value("${jwt.expiration.wechat.refresh}")
    private Long weChatRefreshExpiration;

    /**
     * 获取用户token
     *
     * @param token
     * @return
     */
    public String getUsernameFromToken(String token) {
        String username;
        try {
            final Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 获取用户token
     *
     * @param token
     * @return
     */
    public AuthorityUserDto getUserDetailFromToken(String token) {
        AuthorityUserDto detail;
        try {
            final Claims claims = getClaimsFromToken(token);
            Object  detailObject = claims.get(AUTHORITY_USER_DETAIL);
            Gson gson = new Gson();
            // 解析json
            detail = gson.fromJson(gson.toJson(detailObject), AuthorityUserDto.class);
        } catch (Exception e) {
            detail = null;
        }
        return detail;
    }

    /**
     * 获取token的创建时间
     *
     * @param token
     * @return
     */
    public Date getCreatedDateFromToken(String token) {
        Date created;
        try {
            final Claims claims = getClaimsFromToken(token);
            created = new Date((Long) claims.get(CLAIM_KEY_CREATED));
        } catch (Exception e) {
            created = null;
        }
        return created;
    }

    /**
     * 获取token的过期时间
     *
     * @param token
     * @return
     */
    public Date getExpirationDateFromToken(String token) {
        Date expiration;
        try {
            final Claims claims = getClaimsFromToken(token);
            expiration = claims.getExpiration();
        } catch (Exception e) {
            expiration = null;
        }
        return expiration;
    }

    /**
     * 调用jar生成token令牌
     *
     * @param token
     * @return
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            log.error("解析token是失败:错误信息:{}", com.fourkmiles.common.util.ExceptionUtil.formatException(e));
            claims = null;
        }
        return claims;
    }

    /**
     * 生成过期时间
     *
     * @param tokenExpirationDto
     * @return
     */
    private Date generateExpirationDate(TokenExpirationDto tokenExpirationDto) {
        Date expirationDate = null;
        ChannelEnum channelEnum = tokenExpirationDto.getChannelEnum();
        TokenEnum tokenEnum = tokenExpirationDto.getTokenEnum();
        if (channelEnum.getType() == ChannelEnum.PC_CHANNEL.getType()) {
            if (tokenEnum.getType() == TokenEnum.ACCESS_TOKEN.getType()) {
                expirationDate = new Date(System.currentTimeMillis() + pcAccessExpiration * 1000);
            } else {
                expirationDate = new Date(System.currentTimeMillis() + pcRefreshExpiration * 1000);
            }
        } else {
            if (tokenEnum.getType() == TokenEnum.ACCESS_TOKEN.getType()) {
                expirationDate = new Date(System.currentTimeMillis() + weChatAccessExpiration * 1000);
            } else {
                expirationDate = new Date(System.currentTimeMillis() + weChatRefreshExpiration * 1000);
            }
        }
        return expirationDate;
    }

    /**
     * 校验token是否过期
     *
     * @param token
     * @return
     */
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    /**
     * 生成token
     *
     * @param userDetails
     * @param tokenExpirationDto
     * @return
     */
    public String generateToken(UserDetails userDetails, TokenExpirationDto tokenExpirationDto, AuthorityUserDto authorityUserDto) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        claims.put(AUTHORITY_USER_DETAIL, authorityUserDto);
        return generateToken(claims, tokenExpirationDto);
    }

    /**
     * 生成token
     *
     * @param claims
     * @param tokenExpirationDto
     * @return
     */
    String generateToken(Map<String, Object> claims, TokenExpirationDto tokenExpirationDto) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate(tokenExpirationDto))
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 是否具备刷新条件
     *
     * @param token
     * @return
     */
    public Boolean canTokenBeRefreshed(String token) {
        return !isTokenExpired(token);
    }

    /**
     * 刷新token
     *
     * @param refreshAuthorityQuery
     * @param tokenExpirationDto
     * @return
     */
    public String refreshToken(RefreshAuthorityQuery refreshAuthorityQuery, TokenExpirationDto tokenExpirationDto) {
        String refreshedToken;
        try {
            final Claims claims = getClaimsFromToken(refreshAuthorityQuery.getToken());
            claims.put(CLAIM_KEY_CREATED, new Date());
            refreshedToken = generateToken(claims, tokenExpirationDto);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 校验token是否合法
     *
     * @param token
     * @param userDetails
     * @return
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        final Date created = getCreatedDateFromToken(token);
        return !isTokenExpired(token);
    }
}

这个代码比较多,因为为了兼容我们的业务,做了一些处理,其实最核心就两个方法,创建Token和解密Token,也就是generateToken和getClaimsFromToken。jwt核心类其实就是io.jsonwebtoken.Jwts,借助Jwts类,来创建或者解密Token。

用户授权成功之后,通过generateToken方法创建并返回Token。用户正常请求的时候,解析Token获取其中的用户信息返回给调用者。因为登录成功返回Token涵盖在工具类中,博主这边就不给具体案例了。

三、JWT缺陷以及补救方法

JWT缺陷:

JWT生成的Token是无状态的,也就是不管是谁拿到了Token,都可以借助Token来获取用户信息。这样就会有一个问题,如果子账号权限被禁用,但是因为用户Token过期时间未到,这就会造成数据泄露的风险。

有同学可能会说,那还不简单,直接将Token存储起来,如果退出登陆或者取消授权,就将Token删除即可,如果这样做的话,就相当于将Token从无状态变为有状态的,违背了JWT设计的初衷了。

如果系统允许账号多点登录,那因为每次登录都会生成Token,如果将Token存储到Redis中,假如有人恶意一直登录,那对Redis也是一种伤害。而且对于Token的维护也很麻烦,这将会大大的增加系统的复杂度,所以这种方案不推荐使用。

补救方案:

为了解决这个问题,我们可以将Token分为access_token、refresh_token两种,access_token的生命周期短,refresh_token生命周期长,请求数据采用access_token,如果返回Token失效,可以用refresh_token来刷新Token。当然这仅仅是第一步,我们还需要将refresh_token存储起来,如果子账号授权被取消,就可以将refresh_token删除,然后因为access_token过期时间很短,就可以最大程度保障用户信息安全了,具体流程如下所示:

有同学看了上面的方案,可能会有疑问,那还不是得将refresh_token存储起来,这和存储Token有什么区别呢?

当然有区别了,因为好维护,我们只需要维护refresh_token过期和手动取消授权两种情况,特别是手动取消几乎不会发生,也就是主要是第一种情况了。

如果存储的refresh_token过期,就会要求客户端登录,用户授权登录成功后,就可以生成新的refresh_token,只要将新的refresh_token替换掉旧的refresh_token就可以了。

如果系统对Token时效性要求非常高,退出登陆获取取消授权就马上失效,那建议童鞋们使用Spring Session来实现。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-09-09 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档