密码加密与微服务鉴权JWT

SpringSecurity

Posted by 王明高 on December 7, 2019

1 BCrypt密码加密

1.1 准备工作

任何应用考虑到安全,绝不能明文的方式保存密码。密码应该通过哈希算法进行加密。 有很多标准的算法比如SHA或者MD5,结合salt(盐)是一个不错的选择。 Spring Security 提供了BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密密码。

BCrypt强哈希方法 每次加密的结果都不一样。

(1)demo_user工程的pom引入依赖

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

(2)添加配置类

我们在添加了spring security依赖后,所有的地址都被spring security所控制了,我们目前只是需要用到BCrypt密码加密的部分,所以我们要添加一个配置类,配置为所有地址都可以匿名访问。

/**
 *安全配置类
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/**").permitAll()
            .anyRequest().authenticated()
            .and().csrf().disable();
    }
}

(3)修改demo_user工程的Application, 配置bean

@Bean
public BCryptPasswordEncoder bcryptPasswordEncoder(){
    return new BCryptPasswordEncoder();
}

1.2 用户密码加密

1.2.1 新增用户密码加密

修改demo_user工程的UserService

...
@Autowired
BCryptPasswordEncoder encoder;

public void add(User user) {
    user.setId(idWorker.nextId()+""); //主键值
    //密码加密
    String newpassword = encoder.encode(user.getPassword());//加密后的密码
    user.setPassword(newpassword);
    userDao.save(user);
}

1.2.2 用户登陆密码校验

修改demo_user工程的UserService

...
/**
 * 根据登陆名和密码查询
 * @param loginname
 * @param password
 * @return
 */
 public User findByLoginnameAndPassword(String loginname, String password){
    User user = userDao.findByLoginname(loginname);
    if(user!=null && encoder.matches(password,user.getPassword())){
        return user;
    }else{
        return null;
    }   
 }
...

2 基于JWT的Token认证机制实现

2.1 什么是JWT

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。

2.2 JWT组成

一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。

  • 头部(Header)

头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象

{"typ":"JWT","alg":"HS256"}

在头部指明了签名算法是HS256算法。 我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
  • 载荷(playload)

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

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

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

(2)公共的声明

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

(3)私有的声明

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

这个指的就是自定义的claim。比如前面那个结构举例中的admin和name都属于自定的 claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而 private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。

定义一个payload:

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

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

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
  • 签证(signature)

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

(1)header (base64后的)

(2)payload (base64后的)

(3)secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符 串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第 三部分。

TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

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

2.3 JJWT快速入门

2.3.1 token的创建

(1)创建maven工程,引入依赖

...
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.6.0</version>
</dependency>
...

(2)创建类CreateJwtTest,用于生成token

...
public class CreateJwtTest {
    
    public static void main(String[] args) {
        
        JwtBuilder builder= Jwts.builder().setId("111")
            .setSubject("小明")
            .setIssuedAt(new Date())
            .signWith(SignatureAlgorithm.HS256,"wmg");
        System.out.println(builder.compact());
    }
}
...

setIssuedAt用于设置签发时间

signWith用于设置签名秘钥

(3)测试运行,输出如下:

eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTM0NTh9.gq0J‐cOM_qCNqU_s‐d_IrRytaNenesPmqAIhQpYXHZk

再次运行,会发现每次运行的结果是不一样的,因为我们的载荷中包含了时间。

2.3.2 token的解析

我们刚才已经创建了token ,在web应用中这个操作是由服务端进行然后发给客户 端,客户端在下次向服务端发送请求时需要携带这个token(这就好像是拿着一张门票一 样),那服务端接到这个token 应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果。

创建ParseJwtTest

...
public class ParseJwtTest {
    
    public static void main(String[] args) {
        String token="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTM0NTh9.gq0J‐cOM_qCNqU_s‐d_IrRytaNenesPmqAIhQpYXHZk";
        Claims claims = Jwts.parser().setSigningKey("wmg").parseClaimsJws(token).getBody();
        System.out.println("id:" + claims.getId());
        System.out.println("subject:" + claims.getSubject());
        System.out.println("IssuedAt:" + claims.getIssuedAt());
    }
}

试着将token或签名秘钥篡改一下,会发现运行时就会报错,所以解析token也就是验证 token

2.3.3 token过期校验

有很多时候,我们并不希望签发的token是永久生效的,所以我们可以为token添加一个过期时间

创建CreateJwtTest2

...
public class CreateJwtTest2 {
    
    public static void main(String[] args) {
        
        //为了方便测试,我们将过期时间设置为1分钟
        long now = System.currentTimeMillis();//当前时间
        long exp = now + 1000*60;//过期时间为1分钟
        JwtBuilder builder= Jwts.builder().setId("111")
            .setSubject("小明")
            .setIssuedAt(new Date())
            .signWith(SignatureAlgorithm.HS256,"wmg")
            .setExpiration(new Date(exp));
        System.out.println(builder.compact());  
    }
}
...

setExpiration 方法用于设置过期时间

修改ParseJwtTest

...
public class ParseJwtTest {
   
    public static void main(String[] args) {
        
        String compactJws="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTY1NjksImV4cCI6MTUyMzQxNjYyOX0.Tk91b6mvyjpKcldkic8DgXz0zsPFFnRgTgkgcAsa9cc";
        Claims claims = Jwts.parser().setSigningKey("wmg").parseClaimsJws(compactJws).getBody();
        System.out.println("id:"+claims.getId());
        System.out.println("subject:"+claims.getSubject());
        SimpleDateFormat sdf=new SimpleDateFormat("yyyy‐MM‐dd hh:mm:ss");
        System.out.println("签发时间:"+sdf.format(claims.getIssuedAt()));
        System.out.println("过期时间:"+sdf.format(claims.getExpiration()));
        System.out.println("当前时间:"+sdf.format(new Date()));
    }
}
...

测试运行,当未过期时可以正常读取,当过期时会引发io.

jsonwebtoken.ExpiredJwtException异常。

Exception in thread "main" io.jsonwebtoken.ExpiredJwtException: JWT expired at 2019‐06‐08T21:44:55+0800. Current time: 2019‐06‐08T21:44:56+0800 at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:365) at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:458) at io.jsonwebtoken.impl.DefaultJwtParser.parseClaimsJws(DefaultJwtParser.java :518) at cn.itcast.demo.ParseJwtTest.main(ParseJwtTest.java:13)

2.3.4 自定义claims

我们刚才的例子只是存储了id和subject两个信息,如果你想存储更多的信息(例如角 色)可以定义自定义claims 创建CreateJwtTest3

...
public class CreateJwtTest3 {
    
    public static void main(String[] args){
        
        //为了方便测试,我们将过期时间设置为1分钟
        long now = System.currentTimeMillis();//当前时间
        long exp = now + 1000*60;//过期时间为1分钟
        JwtBuilder builder= Jwts.builder().setId("111")
            .setSubject("小明")
            .setIssuedAt(new Date())
            .signWith(SignatureAlgorithm.HS256,"wmg")
            .setExpiration(new Date(exp))
            .claim("roles","admin")
            .claim("logo","logo.png");
        System.out.println( builder.compact() );
    }
}
...

修改ParseJwtTest

...
public class ParseJwtTest {
    
    public static void main(String[] args) {
        
        String compactJws="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTczMjMsImV4cCI6MTUyMzQxNzM4Mywicm9sZXMiOiJhZG1pbiIsImxvZ28iOiJsb2dvLnBuZyJ9.b11p4g4rE94rqFhcfzdJTPCORikqP_1zJ1MP8KihYTQ";
        Claims claims = Jwts.parser().setSigningKey("itcast").parseClaimsJws(compactJws).getBody();
        System.out.println("id:"+claims.getId());
        System.out.println("subject:"+claims.getSubject());
        System.out.println("roles:"+claims.get("roles"));
        System.out.println("logo:"+claims.get("logo"));
        SimpleDateFormat sdf=new SimpleDateFormat("yyyy‐MM‐dd hh:mm:ss");
        System.out.println("签发时间:"+sdf.format(claims.getIssuedAt()));
        System.out.println("过期时间:"+sdf.format(claims.getExpiration()));
        System.out.println("当前时间:"+sdf.format(new Date()) );
    }
}

3 微服务鉴权

3.1 JWT工具类编写

(1)demo_common工程引入依赖(考虑到工具类的通用性)

...
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.6.0</version>
</dependency>
...

(2)修改demo_common工程,创建util.JwtUtil

...
@ConfigurationProperties("jwt.config")
public class JwtUtil {
    private String key ;
    private long ttl ;//一个小时
    
    public String getKey() {
        return key;
    }
    public void setKey(String key) {
        this.key = key;
    }
    public long getTtl() {
        return ttl;
    }
    public void setTtl(long ttl) {
        this.ttl = ttl;
    }
    
    /**
     * 生成JWT
     * @param id
     * @param subject
     * @return
     */
    public String createJWT(String id, String subject, String roles) {
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        JwtBuilder builder = Jwts.builder().setId(id)
            .setSubject(subject)
            .setIssuedAt(now)
            .signWith(SignatureAlgorithm.HS256, key).claim("roles",roles);
        if (ttl > 0) {
            builder.setExpiration( new Date( nowMillis + ttl));
        }
        return builder.compact();
    }
    
    /**
     * 解析JWT
     * @param jwtStr
     * @return
     */
    public Claims parseJWT(String jwtStr){
        return Jwts.parser()
            .setSigningKey(key)
            .parseClaimsJws(jwtStr)
            .getBody();
    }
}    

(3)修改demo_user工程的application.yml, 添加配置

jwt:
  config:
    key: wmg
    ttl: 360000

3.2 管理员登陆后台签发token

(1)配置bean .修改demo_user工程Application类

@Bean
public JwtUtil jwtUtil(){
    return new util.JwtUtil();
}

(2)修改AdminController的login方法

...
@Autowired
private JwtUtil jwtUtil;
/**
 * 用户登陆
 * @param loginname
 * @param password
 * @return
 */
@RequestMapping(value="/login",method=RequestMethod.POST)
public Result login(@RequestBody Map<String,String> loginMap){
    Admin admin = adminService.findByLoginnameAndPassword(loginMap.get("loginname"),loginMap.get("password"));
    if(admin!=null){
        //生成token
        String token = jwtUtil.createJWT(admin.getId(),admin.getLoginname(), "admin");
        Map map=new HashMap();
        map.put("token",token);
        map.put("name",admin.getLoginname());//登陆名
        return new Result(true,StatusCode.OK,"登陆成功",map);
    }else{
        return new Result(false,StatusCode.LOGINERROR,"用户名或密码错误");
    }
}

测试运行结果

{
	"flag": true,
	"code": 20000,
	"message": "登陆成功",
	"data": {
		"token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI5ODQzMjc1MDc4ODI5MzgzNjgiLCJzdWIiOiJ4aWF vbWkiLCJpYXQiOjE1MjM1MjQxNTksInJvbGVzIjoiYWRtaW4iLCJleHAiOjE1MjM1MjQ1MTl9 ._YF3oftRNTbq9WCD8Jg1tqcez3cSWoQiDIxMuPmp73o",
		"name": "admin"
	}
}

3.3 删除用户功能鉴权

前后端约定:前端请求微服务时需要添加头信息Authorization ,内容为Bearer+空格+token

(1)修改UserController的findAll方法 ,判断请求中的头信息,提取token并验证权限。

...
@Autowired
private HttpServletRequest request;

/**
 * 删除
 * @param id
 */
@RequestMapping(value="/{id}",method= RequestMethod.DELETE)
public Result delete(@PathVariable String id ){
    String authHeader = request.getHeader("Authorization");//获取头信息
    if(authHeader==null){
        return new Result(false,StatusCode.ACCESSERROR,"权限不足");
    }
    if(!authHeader.startsWith("Bearer ")){
        return new Result(false,StatusCode.ACCESSERROR,"权限不足");
    }
    String token=authHeader.substring(7);//提取token
    Claims claims = jwtUtil.parseJWT(token);
    if(claims==null){
        return new Result(false,StatusCode.ACCESSERROR,"权限不足");
    }
    if(!"admin".equals(claims.get("roles"))){
        return new Result(false,StatusCode.ACCESSERROR,"权限不足");
    }
    userService.deleteById(id);
    return new Result(true,StatusCode.OK,"删除成功");
}

3.4 使用拦截器方式实现token鉴权

如果我们每个方法都去写一段代码,冗余度太高,不利于维护,那如何做使我们的代码看起来更清爽呢?我们可以将这段代码放入拦截器去实现

3.4.1 添加拦截器

Spring为我们提供了org

.springframework.web.servlet.handler.HandlerInterceptorAdapter这个适配器, 继承此类,可以非常方便的实现自己的拦截器。他有三个方法:

分别实现预处理、后处理(调用了Service并返回ModelAndView,但未进行页面渲染)、返回处理(已经渲染了页面)

  • 在preHandle中,可以进行编码、安全控制等处理;
  • 在postHandle中,有机会修改ModelAndView;
  • 在afterCompletion中,可以根据ex是否为null判断是否发生了异常,进行日志记录。

(1)创建拦截器类。创建 com.demo.user.filter.JwtFilter

...
@Component
public class JwtFilter extends HandlerInterceptorAdapter {
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @Override
    public boolean preHandle(HttpServletRequest request,HttpServletResponse response, Object handler) throws Exception {
        System.out.println("经过了拦截器");
        return true;
    }
}
...

(2)配置拦截器类,创建com.demo.user.ApplicationConfig

...
@Configuration
public class ApplicationConfig extends WebMvcConfigurationSupport {
    
    @Autowired
    private JwtFilter jwtFilter;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtFilter).
            addPathPatterns("/**").
            excludePathPatterns("/**/login");
    }
}
...

3.4.2 拦截器验证token

(1)修改拦截器类 JwtFilter

...
@Component
public class JwtFilter extends HandlerInterceptorAdapter {
    @Autowired
    private JwtUtil jwtUtil;
    
    @Override
    public boolean preHandle(HttpServletRequest request,HttpServletResponse response, Object handler) throws Exception {
        System.out.println("经过了拦截器");
        final String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            final String token = authHeader.substring(7); // The part after "Bearer " 
            Claims claims = jwtUtil.parseJWT(token);
            if (claims != null) {
                if("admin".equals(claims.get("roles"))){//如果是管理员
                	request.setAttribute("admin_claims", claims);
                }
                if("user".equals(claims.get("roles"))){//如果是用户
                	request.setAttribute("user_claims", claims);
                }
            }
        }
        return true;
    }
}

(2)修改UserController的delete方法

/**
 * 删除
 * @param id
 */
@RequestMapping(value="/{id}",method= RequestMethod.DELETE)
public Result delete(@PathVariable String id ){
    Claims claims=(Claims) request.getAttribute("admin_claims");
    if(claims==null){
        return new Result(true,StatusCode.ACCESSRROR,"无权访问");
    }
    userService.deleteById(id);
    return new Result(true,StatusCode.OK,"删除成功");
}