关于身份认证的那些事(一)

目录

1 前言

2 常见的身份认证方案

2.1 Cookie

2.1.1 初识Cookie

2.1.2 透过代码再识Cookie

2.1.3 关于Cookie的其他补充 

2.2 Session

2.2.1 初识Session

2.2.2 Session的来龙去脉

2.2.3 Session共享解决方案

2.3 Token

2.3.1 初识Token

2.3.2 “小话”Jwt

3 小结

4 参考链接


1 前言

        刚接触Java Web开发时,记得就是从系统登录功能开始的,当时学的什么至今也只留下一些名词:Cookie、Session,到后来了解到(听过)的Jwt、Oauth2、Spring-Security、Shiro、SSO……

        概念与技术框架越来越多,总有种似懂非懂的感觉。因此,下决心好好了解一下这些技术,并记录下来。由于内容过多,并且自己也没有完全学完,故分为几个部分来介绍。本篇博客想先从整体上梳理一下这些概念(建议对这些概念也没有搞清的童鞋看看~),更多详细的内容将在后续篇章中一一介绍。

2 常见的身份认证方案

2.1 Cookie

2.1.1 初识Cookie

        想必大家都有过这样的经历:使用某个浏览器A打开了某个网页并已经登录过了,突然某个功能页面要求使用另一种浏览器B,这时我们如果把A中的访问地址直接拷贝到B中,会发现又要重新登录。

        这个其实就是因为我们在A浏览器登录后,A浏览器将我们在这个网站上身份认证所必须的数据存在了Cookie中,以后每次请求都会携带这些Cookie数据,从而避免每次操作还需要登录。而B浏览器器并没有这些身份认证需要的数据,那当然需要再次登录咯。

        我们先来看看火狐浏览器缓存的Cookie长啥样(F12=>存储=>Cookie),下图可以发现其实就CSDN这个站点来说也存了挺多项数据的,都是一个个键值对。

2.1.2 透过代码再识Cookie

        首先搭建一个空的Springboot项目,创建一个IndexController用于测试。

@RestController
public class IndexController {
    
    @RequestMapping("/index")
    public String index(HttpServletRequest request, HttpServletResponse response) {
        // 从request中获取cookie并打印
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0)
            for (Cookie cookie : cookies)
                System.out.println("name: " +cookie.getName() + " ,value: " + cookie.getValue());
   
        response.addCookie(new Cookie("userid", "1"));
        response.addCookie(new Cookie("username", "zhangsan"));
        return "success";
    }
    
}

        通过response对象的addCookie(Cookie cookie)方法,可以向浏览器中添加Cookie。比如上面的例子直接将用户ID和用户名等不敏感又可以唯一标识用户的简短数据写入Cookie,这样就解决了Http请求无状态,而后端又需要区分不同用户请求的问题啦。

        看看浏览器连续发两次请求有何变化吧:

        第一次

        可看到我们在response对象中调用addCookie(),对应到浏览器则是在响应头中收到了两条Set-Cookie指令。

        注释掉addCookie()方法后重启项目,再次访问如下:

第二次

         针对这个站点的后续请求也都会携带上我们设置的Cookie。

        后端确实在request对象中获取到了cookie对象,并在控制台打印了出来。我们试试关闭整个浏览器,再访问一次,结果发现请求头中又不存在之前设置过的cookie了,因为此前设置的cookie随浏览器会话的关闭被清除了

2.1.3 关于Cookie的其他补充 

        Java中的Cookie对像实际上是定义在javax.servlet.http包下,可以看到除了name和value其实还有不少属性,下面就来聊一聊。

属性类型含义
nameStringcookie的名字,只能是字符串
valueStringcookie的值,只能是字符串,对象需要序列化后存储,中文需要URLEncoder.encode()进行编码
versionintcookie协议规范版本,0表示为Netscape公司制定的版本,1表示使用RFC 2109规范解析。但新版本暂不支持
commentString对该项cookie作用的说明
domainString在RFC 2109中做出规定:domain一般以“.”开始,如:domain=“.foo.com”表示该cookie对所有域名为.foo.com及其子域名均可见
maxAgeint以秒为单位的cookie存活时间。默认-1表示不存储cookie,随浏览器关闭丢失;0表示删除cookie;>0表示设置了cookie存活的时间为maxAge秒
pathString只有访问的地址包含cookie的path值时,才能得到cookie对象
secureboolean设置为true表示仅当使用安全传输协议(如https、ssl)时,浏览器才会携带cookie
httpOnlyboolean设置为true表示JS脚本将无法读取到cookie信息,能有效的防止XSS攻击,增加了安全性

        下面做几个小例子试一试上面几个属性。

  • 先试试path属性
@RestController
@RequestMapping("cookie")
public class IndexController {
    // 设置Cookie
    @RequestMapping("/set")
    public String setCookie(HttpServletRequest request, HttpServletResponse response) {
        Cookie cookie = new Cookie("k1", "v1");
        cookie.setComment("test \"path\"");
        // 设置path
        cookie.setPath("/cookie/path1");
        response.addCookie(cookie);
        return "set ok!";
    }

    // path1应当可以访问到设置的Cookie
    @RequestMapping("/path1")
    public String path1(HttpServletRequest request, HttpServletResponse response) {
        String cookieStr = "访问path1下的cookie\n";
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0)
            for (Cookie cookie : cookies)
                cookieStr += ("name: " +cookie.getName() + " ,value: " + cookie.getValue() + "\n");
        return cookieStr;
    }
    
    // path不能访问设置的Cookie
    @RequestMapping("/path2")
    public String path2(HttpServletRequest request, HttpServletResponse response) {
        String cookieStr = "访问path2下的cookie\n";
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length > 0)
            for (Cookie cookie : cookies)
                cookieStr += ("name: " +cookie.getName() + " ,value: " + cookie.getValue() + "\n");
        return cookieStr;
    }
}

        分别访问/path1和/path2,观察cookie携带情况。 

  

  • 再试试secure属性(有点小疑惑)

         设置secure为true后,可以在浏览器的cookie列表中观察到该cookie的secure属性打了勾。再访问一下获取该cookie,发现居然在http协议下也能访问到!!!一个可能的原因是该cookie是在http协议下设置的,这个问题笔者暂时也没有搞明白,有懂得大佬可以评论见。

@RequestMapping("/set")
public String setCookie(HttpServletRequest request, HttpServletResponse response) {
    Cookie cookie = new Cookie("k1", "v1");
    cookie.setSecure(true);
    response.addCookie(cookie);
    return "set ok!";
}

@RequestMapping("/get")
public String get(HttpServletRequest request, HttpServletResponse response) {
    String cookieStr = "获取cookie\n";
    Cookie[] cookies = request.getCookies();
    if(cookies != null && cookies.length > 0)
        for (Cookie cookie : cookies)
            cookieStr += ("name: " +cookie.getName() + " ,value: " + cookie.getValue() + "\n");
    return cookieStr;
}

2.2 Session

2.2.1 初识Session

        Session与Cookie类似,也是可以用于解决Http请求无状态的问题。只不过Session是存储在服务端的,需要占用服务器资源。

        下面的代码展示了从request对象中获取session对象,再返回给前端sessionId。

@RestController
public class IndexController {

    @RequestMapping("/session")
    public String getSession(HttpServletRequest request){
        HttpSession session = request.getSession();
        String sessionId = session.getId();
        return "SessionId: " + sessionId;
    }
}

         刷新N次后,发现每一次返回的sessionId都不变,并且在每一次请求中都会携带一项cookie(JSESSIONID=xxxx)。

        其原理就是:浏览器首次访问后台,服务端发现没有携带JSESSIONID的cookie,服务端认为这是一个新的请求,并为其创建了session,返回sessionId。以后每次请求,浏览器携带该sessionId,服务端便能区分不能浏览器的请求了。

        因此,session是可以和cookie配合使用的,将当前操作用户的相关信息存储在服务端(类似于抽屉),存储的数据更多、更安全,将session的唯一标识(类似于开抽屉的唯一钥匙)存储在客户端。

2.2.2 Session的来龙去脉

        由于本节会涉及部分Web Container的知识,因此详细的分析可能会在后续单独篇章中介绍,本节仅介绍大致流程。

        我们通过request.getSession()获取到的实际上是StandardSessionFacade对象,它是真正StandardSession对象的对外形式(门面模式)。

public class StandardSessionFacade implements HttpSession {
    /**
     * Construct a new session facade.
     *
     * @param session The session instance to wrap
     */
    public StandardSessionFacade(HttpSession session) {
        this.session = session;
    }

    /**
     * Wrapped session object.
     */
    private final HttpSession session;
}

         下面我们追踪一下session的创建过程(先清空浏览器中的cookie:JSESSIONID)。

         总结一下:

        1.request对象中会包含一个当前请求对应的活跃的session对象

        2.getSession()时会首先判断该session是否可用(isValid、isExpire),可用则直接返回

        3.request中的session失效时会从manager对象(StandardManager)中获取session,正常获取到则返回该session对象

        4.当session为null时,需要创建session的情况下,执行ManageBase中的createSession()方法,创建一个空session对象,并为之初始化

2.2.3 Session共享解决方案

        在服务分布式部署的情形下,多次请求不一定会映射到同一服务器。为了避免在A服务器中已经登录并保存session后,B服务器无法及时获取A服务器中session的问题,我们可以引入缓存中间件来集中存储session对象,从而保证了服务端无状态。常见的解决方案,我们可以基于Redis内存数据库来实现Session在多个服务间的共享。

        原理很简单,下面我们用代码实践一下吧。工程目录如下:

        -User:用户实体类

        -LoginIntercepter:全局拦截器,用于校验用户是否登录

@Component
public class LoginIntercepter implements HandlerInterceptor {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Cookie[] cookies = request.getCookies();
        if(cookies != null)
            for (Cookie cookie : cookies)
                if("TOKEN_ID".equals(cookie.getName()))
                    return validateToken(cookie);
        return false;
    }

    private boolean validateToken(final Cookie cookie) {
        Object o = redisTemplate.opsForValue().get(cookie.getValue());
        if(o != null) {
            // 反序列化
            try{
                User user = JSONObject.parseObject(o.toString(), User.class);
                if(user != null)
                    return true;
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return false;
    }
}

        -WebConfig:注册拦截器,设置拦截范围

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Autowired
    LoginIntercepter loginIntercepter;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration registration = registry.addInterceptor(loginIntercepter);
        registration.addPathPatterns("/**");
        registration.excludePathPatterns("/login");
    }
}

        -IndexController:模拟登录的接口

@RestController
public class IndexController {

    public static final String PREFIX = "user:";

    @Autowired
    private RedisTemplate redisTemplate;

    @RequestMapping("/login")
    public String login(HttpServletRequest request, HttpServletResponse response, User user){
        String key = PREFIX + UUID.randomUUID().toString().replace("-", "");
        System.out.println(JSON.toJSONString(user));
        redisTemplate.opsForValue().set(key, JSON.toJSONString(user));
        response.addCookie(new Cookie("TOKEN_ID", key));
        return "login ok";
    }

    /**
     * 登录后访问该地址测试
     * @return
     */
    @RequestMapping("/index")
    public String index(){
        return "index ok";
    }
}

        -RedisConfig:redisTemplate配置类,自定义序列化方式,解决redis-client编码格式问题

@Component
public class RedisConfig {
	/**
     * 实例化 RedisTemplate
     * @param redisConnectionFactory
     * @return
     */
    @Bean(name = "redisTemplate")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // 使用Jackson2JsonRedisSerialize 替换默认序列化
        //设置value的序列化规则
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        //设置key的序列化规则
        RedisSerializer<String> stringSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringSerializer);
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(stringSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

具体步骤如下:

        1.安装并启动redis(此步骤省略)

        2.Springboot中导入依赖

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

        3.用户登录后保存用户信息至redis

        4.拦截器中从redis中查询用户数据并验证

2.3 Token

        在上面session的解决方案中,我们通过将用户信息保存在session中,以便在每次客户端请求的会话中获取操作用户信息。若客户端JSESSIONID对应的session对象中正常保存了用户数据,服务端认为用户已认证,否则认为未通过认证。这种方案一是需要占用大量服务器资源,二是在分布式环境下需要考虑session共享的问题。

        本小节我们要介绍的基于Token认证方案则可以较好地解决这些问题。

2.3.1 初识Token

        Token是翻译过来是“令牌”,即访问某些受保护资源的一种凭证。基于Token的身份认证大致步骤如下:

  1. 客户端发起访问请求,并提供用户名和密码
  2. 服务端对用户名和密码进行认证,并生成Token返回给客户端
  3. 客户端在以后的请求头或URL中都带上该Token
  4. 服务端在拦截器中,验证请求中携带的Token,验证通过则返回数据,否则返回错误码

2.3.2 “小话”Jwt

        Jwt可以理解为Token的一种实现规范,但它仅仅是一种规范,具体的实现不同的编程语言都有好几种实现方案。

      Jwt(Json web token)是一种基于Json在多方之间进行安全的信息交换的标准(RFC 7519)。由于它传输的信息是通过HMAC、RSA或ECDSA算法进行数字签名认证的,因此保证了数据是可信的。Jwt的官网中提到了两种适合使用Jwt的场景:一是授权(Authorization),二是信息交换(Information Exchange)。

摘自官网(JSON Web Token Introduction - jwt.io

  • Authorization: This is the most common scenario for using JWT. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token. Single Sign On(单点登录) is a feature that widely uses JWT nowadays, because of its small overhead and its ability to be easily used across different domains.

  • Information Exchange: JSON Web Tokens are a good way of securely transmitting information between parties. Because JWTs can be signed—for example, using public/private key pairs—you can be sure the senders are who they say they are. Additionally, as the signature is calculated using the header and the payload, you can also verify that the content hasn't been tampered with.

        Jwt的格式一共包含三部分:header、payload、signature,类似“xxxxx.yyyyy.zzzzz”的形式。

  • header

       header使用base64加密后作为token的第一部分。

{ 
  "alg": "HS256",  // 签名使用的算法 HMAC SHA256 or RSA
  "typ": "JWT"     // 令牌类型
}
  • payload

       payload中包含claims,主要包括三种claim:Registered claims(规范中预定义的)、Public claims、Private claims(自定义的数据)。使用base64加密后作为token的第二部分。由于base64加密是可以被反破解的,因此payload中不应存放任何敏感信息

{
  "sub": "1234567890",  // token面向的用户
  "name": "John Doe",   // 自定义内容
  "admin": true         // 自定义内容
}
  • signature

       第三部分需要结合前两部分的结果,进行一次加密。比如上例中第三部分的运算过程如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

        前面说过,Jwt只是定义一种token生成的规范,那么在Java语言中常用的对应实现就是jjwt。下面来稍微实战用一下吧。

@SpringBootTest
public class JwtTest {

    public static final String secretKey = "0123456789";

    @Test
    public void createJwt(){
        // 过期时间3分钟
        long expireTime = System.currentTimeMillis() + 1000 * 60 * 3;
        // 自定义载荷
        Map<String, Object> customClaims = new HashMap<>();
        customClaims.put("userId", "001");
        customClaims.put("userName", "张三");
        customClaims.put("date", new Date());

        JwtBuilder builder = Jwts.builder()
                .setId("123")
                .setClaims(customClaims)
                // 签发时间
                .setIssuedAt(new Date())
                // 设置加密算法和秘钥,秘钥可加密
                .signWith(SignatureAlgorithm.HS256, secretKey);
        builder.setExpiration(new Date(expireTime));
        String token = builder.compact();
        System.out.println(token);
    }

    @Test
    public void parseJwt(){
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJkYXRlIjoxNjMyNjUxMjYyMzQ1LCJ1c2VyTmFtZSI6IuW8oOS4iSIsImV4cCI6MTYzMjY1MTQ0MiwidXNlcklkIjoiMDAxIiwiaWF0IjoxNjMyNjUxMjYyfQ.l3JJmweWlOINtslAVIeicWHnoJyMC08sRG20uykE5R4";
        
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token).getBody();

        if(claims != null) {
            System.out.println("签发时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:SS").format(claims.getIssuedAt()));
            System.out.println("过期时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:SS").format(claims.getExpiration()));
            System.out.println("userId: " + claims.get("userId"));
            System.out.println("userName: " + claims.get("userName"));
            System.out.println("date: " + claims.get("date"));
        }
    }
}

        先调用createJwt(),生成token后调用parseJwt() ,token有效期内控制台输出如下:

        过期的token在解析时会抛出异常:

3 小结

        本篇博文主要介绍了基于Cookie、Session、Jwt进行身份认证的思路与优缺点。不管哪种方式,最终都要通过拦截器对用户请求进行校验,包括是否已经进行身份认证、用户是否被授权进行此项操作等。

        后续我们将继续分享如何基于SpringSecurity、Shiro等安全框架,更快地搭建我们的认证与权限管理体系,以及一些常用的二维码登录、第三方授权登录的案例。

        小伙伴们,敬请期待吧~

4 参考链接

第2章 实现用户登录以及分布式session功能_哔哩哔哩_bilibili

2021最全【B站课件】SpringSecurity+JWT项目实战之Java权限管理,从入门到精通Spring Security实战-完整版教程_哔哩哔哩_bilibili

Tomcat 是如何管理Session的? - 知乎

彻底理解cookie,session,token - 墨颜丶 - 博客园

SpringBoot 集成JJwt的使用 - 简书

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值