Spring Security 结合 JWT

概述

  1. 使用 JWT 做权限验证,相比 Session 的优点是,Session 需要占用大量服务器内存,并且在多服务器时就会涉及到共享 Session 问题,在手机等移动端访问时比较麻烦
  2. 而 JWT 无需存储在服务器,不占用服务器资源(也就是无状态的),用户在登录后拿到 Token 后,访问需要权限的请求时附上 Token(一般设置在Http请求头),JWT 不存在多服务器共享的问题,也没有手机移动端访问问题,若为了提高安全,可将 Token 与用户的 IP 地址绑定起来
    案例源码下载

前端流程

  1. 用户通过 AJAX 进行登录得到一个 Token
  2. 之后访问需要权限请求时附上 Token 进行访问
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="http://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script>
    <script type="application/javascript">
        var header = "";
        function login() {
            $.post("http://localhost:8080/auth/login", {
                username: $("#username").val(),
                password: $("#password").val()
            }, function (data) {
                console.log(data);
                header = data;
            })
        }
        function toUserPageBtn() {
            $.ajax({
                type: "get",
                url: "http://localhost:8080/userpage",
                beforeSend: function (request) {
                    request.setRequestHeader("Authorization", header);
                },
                success: function (data) {
                    console.log(data);
                }
            });
        }
    </script>
</head>
<body>
    <fieldset>
        <legend>Please Login</legend>
        <label>UserName</label><input type="text" id="username">
        <label>Password</label><input type="text" id="password">
        <input type="button" onclick="login()" value="Login">
    </fieldset>
    <button id="toUserPageBtn" onclick="toUserPageBtn()">访问UserPage</button>
</body>
</html>

后端流程(Spring Boot + Spring Security + JJWT)

思路:

  1. 创建用户、权限实体类与数据传输对象

  2. 编写 Dao 层接口,用于获取用户信息

  3. 实现 UserDetails(Security 支持的用户实体对象,包含权限信息)

  4. 实现 UserDetailsSevice(从数据库中获取用户信息,并包装成UserDetails)

  5. 编写 JWTToken 生成工具,用于生成、验证、解析 Token

  6. 配置 Security,配置请求处理 与 设置 UserDetails 获取方式为自定义的 UserDetailsSevice

  7. 编写 LoginController,接收用户登录名密码并进行验证,若验证成功返回 Token 给用户

  8. 编写过滤器,若用户请求头或参数中包含 Token 则解析,并生成 Authentication,绑定到 SecurityContext ,供 Security 使用

  9. 用户访问了需要权限的页面,却没附上正确的 Token,在过滤器处理时则没有生成 Authentication,也就不存在访问权限,则无法访问,否之访问成功

编写用户实体类,并插入一条数据

User(用户)实体类

@Data
@Entity
public class User {
    @Id
    @GeneratedValue
    private int id;
    private String name;
    private String password;
    @ManyToMany(cascade = {CascadeType.REFRESH}, fetch = FetchType.EAGER)
    @JoinTable(name = "user_role", joinColumns = {@JoinColumn(name = "uid", referencedColumnName = "id")}, inverseJoinColumns = {@JoinColumn(name = "rid", referencedColumnName = "id")})
    private List<Role> roles;
} 

Role(权限)实体类

@Data
@Entity
public class Role {
    @Id
    @GeneratedValue
    private int id;
    private String name;
    @ManyToMany(mappedBy = "roles")
    private List<User> users;
}

插入数据

User 表

id name password
1 linyuan 123

Role 表

id name
1 USER

User_ROLE 表

uid rid
1 1

Dao 层接口,通过用户名获取数据,返回值为 Java8 的 Optional 对象

public interface UserRepository extends Repository<User,Integer> {
    Optional<User> findByName(String name);
}

编写 LoginDTO,用于与前端之间数据传输

@Data
public class LoginDTO implements Serializable {
    @NotBlank(message = "用户名不能为空")
    private String username;
    @NotBlank(message = "密码不能为空")
    private String password;
}

编写 Token 生成工具,利用 JJWT 库创建,一共三个方法:生成 Token(返回String)、解析 Token(返回Authentication认证对象)、验证 Token(返回布尔值)

@Component
public class JWTTokenUtils {

    private final Logger log = LoggerFactory.getLogger(JWTTokenUtils.class);

    private static final String AUTHORITIES_KEY = "auth";

    private String secretKey;           //签名密钥

    private long tokenValidityInMilliseconds;       //失效日期

    private long tokenValidityInMillisecondsForRememberMe;      //(记住我)失效日期

    @PostConstruct
    public void init() {
        this.secretKey = "Linyuanmima";
        int secondIn1day = 1000 * 60 * 60 * 24;
        this.tokenValidityInMilliseconds = secondIn1day * 2L;
        this.tokenValidityInMillisecondsForRememberMe = secondIn1day * 7L;
    }

    private final static long EXPIRATIONTIME = 432_000_000;

    //创建Token
    public String createToken(Authentication authentication, Boolean rememberMe){
        String authorities = authentication.getAuthorities().stream()       //获取用户的权限字符串,如 USER,ADMIN
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();              //获取当前时间戳
        Date validity;                                          //存放过期时间
        if (rememberMe){
            validity = new Date(now + this.tokenValidityInMilliseconds);
        }else {
            validity = new Date(now + this.tokenValidityInMillisecondsForRememberMe);
        }

        return Jwts.builder()                                   //创建Token令牌
                .setSubject(authentication.getName())           //设置面向用户
                .claim(AUTHORITIES_KEY,authorities)             //添加权限属性
                .setExpiration(validity)                        //设置失效时间
                .signWith(SignatureAlgorithm.HS512,secretKey)   //生成签名
                .compact();
    }

    //获取用户权限
    public Authentication getAuthentication(String token){
        System.out.println("token:"+token);
        Claims claims = Jwts.parser()                           //解析Token的payload
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
      
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))         //获取用户权限字符串
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());                                                  //将元素转换为GrantedAuthority接口集合

        User principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    //验证Token是否正确
    public boolean validateToken(String token){
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);   //通过密钥验证Token
            return true;
        }catch (SignatureException e) {                                     //签名异常
            log.info("Invalid JWT signature.");
            log.trace("Invalid JWT signature trace: {}", e);
        } catch (MalformedJwtException e) {                                 //JWT格式错误
            log.info("Invalid JWT token.");
            log.trace("Invalid JWT token trace: {}", e);
        } catch (ExpiredJwtException e) {                                   //JWT过期
            log.info("Expired JWT token.");
            log.trace("Expired JWT token trace: {}", e);
        } catch (UnsupportedJwtException e) {                               //不支持该JWT
            log.info("Unsupported JWT token.");
            log.trace("Unsupported JWT token trace: {}", e);
        } catch (IllegalArgumentException e) {                              //参数错误异常
            log.info("JWT token compact of handler are invalid.");
            log.trace("JWT token compact of handler are invalid trace: {}", e);
        }
        return false;
    }
}

实现 UserDetails 接口,代表用户实体类,在我们的 User 对象上在进行包装,包含了权限等性质,可以供 Spring Security 使用

public class MyUserDetails implements UserDetails{

    private User user;

    public MyUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<Role> roles = user.getRoles();
        List<GrantedAuthority> authorities = new ArrayList<>();
        StringBuilder sb = new StringBuilder();
        if (roles.size()>=1){
            for (Role role : roles){
                authorities.add(new SimpleGrantedAuthority(role.getName()));
            }
            return authorities;
        }
        return AuthorityUtils.commaSeparatedStringToAuthorityList("");
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

实现 UserDetailsService 接口,该接口仅有一个方法,用来获取 UserDetails,我们可以从数据库中获取 User 对象,然后将其包装成 UserDetails 并返回

@Service
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        //从数据库中加载用户对象
        Optional<User> user = userRepository.findByName(s);
        //调试用,如果值存在则输出下用户名与密码
        user.ifPresent((value)->System.out.println("用户名:"+value.getName()+" 用户密码:"+value.getPassword()));
        //若值不再则返回null
        return new MyUserDetails(user.orElse(null));
    }
}

编写过滤器,用户如果携带 Token 则获取 Token,并根据 Token 生成 Authentication 认证对象,并存放到 SecurityContext 中,供 Spring Security 进行权限控制

public class JwtAuthenticationTokenFilter extends GenericFilterBean {

    private final Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);

    @Autowired
    private JWTTokenUtils tokenProvider;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        System.out.println("JwtAuthenticationTokenFilter");
        try {
            HttpServletRequest httpReq = (HttpServletRequest) servletRequest;
            String jwt = resolveToken(httpReq);
            if (StringUtils.hasText(jwt) && this.tokenProvider.validateToken(jwt)) {            //验证JWT是否正确
                Authentication authentication = this.tokenProvider.getAuthentication(jwt);      //获取用户认证信息
                SecurityContextHolder.getContext().setAuthentication(authentication);           //将用户保存到SecurityContext
            }
            filterChain.doFilter(servletRequest, servletResponse);
        }catch (ExpiredJwtException e){                                     //JWT失效
            log.info("Security exception for user {} - {}",
                    e.getClaims().getSubject(), e.getMessage());

            log.trace("Security exception trace: {}", e);
            ((HttpServletResponse) servletResponse).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
    }

    private String resolveToken(HttpServletRequest request){
        String bearerToken = request.getHeader(WebSecurityConfig.AUTHORIZATION_HEADER);         //从HTTP头部获取TOKEN
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
            return bearerToken.substring(7, bearerToken.length());                              //返回Token字符串,去除Bearer
        }
        String jwt = request.getParameter(WebSecurityConfig.AUTHORIZATION_TOKEN);               //从请求参数中获取TOKEN
        if (StringUtils.hasText(jwt)) {
            return jwt;
        }
        return null;
    }
}

编写 LoginController,用户通过用户名、密码访问 /auth/login,通过 LoginDTO 对象接收,创建一个 Authentication 对象,代码中为 UsernamePasswordAuthenticationToken,判断对象是否存在,通过 AuthenticationManager 的 authenticate 方法对认证对象进行验证,AuthenticationManager 的实现类 ProviderManager 会通过 AuthentionProvider(认证处理) 进行验证,默认 ProviderManager 调用 DaoAuthenticationProvider 进行认证处理,DaoAuthenticationProvider 中会通过 UserDetailsService(认证信息来源) 获取 UserDetails ,若认证成功则返回一个包含权限的 Authention,然后通过 SecurityContextHolder.getContext().setAuthentication() 设置到 SecurityContext 中,根据 Authentication 生成 Token,并返回给用户

@RestController
public class LoginController {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JWTTokenUtils jwtTokenUtils;

    @RequestMapping(value = "/auth/login",method = RequestMethod.POST)
    public String login(@Valid LoginDTO loginDTO, HttpServletResponse httpResponse) throws Exception{
        //通过用户名和密码创建一个 Authentication 认证对象,实现类为 UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDTO.getUsername(),loginDTO.getPassword());
        //如果认证对象不为空
        if (Objects.nonNull(authenticationToken)){
            userRepository.findByName(authenticationToken.getPrincipal().toString())
                    .orElseThrow(()->new Exception("用户不存在"));
        }
        try {
            //通过 AuthenticationManager(默认实现为ProviderManager)的authenticate方法验证 Authentication 对象
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            //将 Authentication 绑定到 SecurityContext
            SecurityContextHolder.getContext().setAuthentication(authentication);
            //生成Token
            String token = jwtTokenUtils.createToken(authentication,false);
            //将Token写入到Http头部
            httpResponse.addHeader(WebSecurityConfig.AUTHORIZATION_HEADER,"Bearer "+token);
            return "Bearer "+token;
        }catch (BadCredentialsException authentication){
            throw new Exception("密码错误");
        }
    }
}

编写 Security 配置类,继承 WebSecurityConfigurerAdapter,重写 configure 方法

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String AUTHORIZATION_HEADER = "Authorization";

    public static final String AUTHORIZATION_TOKEN = "access_token";

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                //自定义获取用户信息
                .userDetailsService(userDetailsService)
                //设置密码加密
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置请求访问策略
        http
                //关闭CSRF、CORS
                .cors().disable()
                .csrf().disable()
                //由于使用Token,所以不需要Session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //验证Http请求
                .authorizeRequests()
                //允许所有用户访问首页 与 登录
                .antMatchers("/","/auth/login").permitAll()
                //其它任何请求都要经过认证通过
                .anyRequest().authenticated()
                //用户页面需要用户权限
                .antMatchers("/userpage").hasAnyRole("USER")
                .and()
                //设置登出
                .logout().permitAll();
        //添加JWT filter 在
        http
                .addFilterBefore(genericFilterBean(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public GenericFilterBean genericFilterBean() {
        return new JwtAuthenticationTokenFilter();
    }
}

编写用于测试的Controller

@RestController
public class UserController {

    @PostMapping("/login")
    public String login() {
        return "login";
    }

    @GetMapping("/")
    public String index() {
        return "hello";
    }

    @GetMapping("/userpage")
    public String httpApi() {
        System.out.println(SecurityContextHolder.getContext().getAuthentication().getPrincipal());
        return "userpage";
    }

    @GetMapping("/adminpage")
    public String httpSuite() {
        return "userpage";
    }

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容