spingboot springsecurity 集成 jwt(token)验证


依赖

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-consul-discovery'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'org.springframework.security:spring-security-test'
    implementation 'com.github.ladyishenlong:response-utils:1.0'
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
  • 使用gradle构建的项目
  • 主要是spring security的依赖以及jjwt作为token生成和解析的工具
  • 关于jwt推荐一个网站:https://jwt.io/
  • consul是注册中心,与本文无关可以去除

用户信息

@Data
@Component
public class Student {

   public String username = "123";
    public String password = "456";
    public String verificationcode = "789"; //验证码
    public String secret = "secretKey"; // token的密钥
    public Set<GrantedAuthority> authorities; //用户权限

    public Set<GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> authorities = new HashSet<>();
        authorities.add(new SimpleGrantedAuthority("root"));
        authorities.add(new SimpleGrantedAuthority("admin"));
        return authorities;
    }
}
  • 随意编写了模拟数据库内用户信息的类,使用@Autowired注入

spring security配置

这里先说几句,用jwt进行用户验证其实并不难,自己写个过滤器或者拦截器总能搞定,但麻烦的是如何与spring security框架进行集成,原因自然是因为spring security框架原本使用的是用户名,密码以及session进行登录验证的,于是需要对这些验证的地方进行改造才行

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    @Autowired
    private LoginUserDetailsService loginUserDetailsService;


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.headers().cacheControl();//禁用缓存

        http
                .cors()
                .and()
                .formLogin().disable()
                .csrf().disable()//禁用csrf防护
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//关闭session

                .and()

                //配置请求的权限
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/test").permitAll()

                //需要特定用户的权限
                .antMatchers("/test3").access(
                        "@AuthService.role('bigboss',request)")
                //普通的请求
                .anyRequest().access("@AuthService.auth(request)")

                .and()
                .authenticationProvider(getLoginAuthProvider())
                .httpBasic()

                .and()
                .exceptionHandling()
                //未授权处理
                .authenticationEntryPoint(new UnAuthorizedEntryPoint())

                .and()
                .addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
        ;

    }


    @Bean
    public LoginAuthProvider getLoginAuthProvider() {
        //采用该方式初始化,在LoginAuthProvider中除了构造函数之外可以依赖注入
        return new LoginAuthProvider(loginUserDetailsService);
    }
    
}
  • 这是security的配置,用@EnableWebSecurity开启security的防护,@EnableWebSecurity包含了@Configuration,而@Configuration包含了@Component注解,自然无需再加上这些注解了
  • 这个类继承自WebSecurityConfigurerAdapter类重写了configure方法
  • 因为要使用jwt做验证,所以讲session先关闭掉,至于configure中对security的改动接下来就截取出来一点一点说

登录请求

  • 首先说的是登录请求,在configure方法中的代码是:
 .and()
                .addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
  • addFilterBefore方法的作用是在指定的过滤器的顺序之前再添加一个过滤器,这里我是在UsernamePasswordAuthenticationFilter.class过滤器前添加了个自定义的过滤器
@Slf4j
public class UserAuthFilter extends UsernamePasswordAuthenticationFilter {

    private static final String VERIFICATION_CODE = "verificationcode";//验证码

    public UserAuthFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {
        setFilterProcessesUrl(defaultFilterProcessesUrl);
        setAuthenticationManager(authenticationManager);
    }


    /**
     * 登录接口,用户粗
     *
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {

        String username = obtainUsername(request);//用户名
        String password = obtainPassword(request);//密码
        String verificationcode = obtainVerificationCode(request);//验证码

        if (StringUtils.isEmpty(username)) username = "";
        if (StringUtils.isEmpty(password)) password = "";
        if (StringUtils.isEmpty(verificationcode)) verificationcode = "";

        UserAuthToken userAuthToken = new UserAuthToken(username, password, verificationcode);

        //将post请求传入的用户信息放入
        return getAuthenticationManager().authenticate(userAuthToken);
    }


    /**
     * 登录请求成功返回的的信息
     * 在这里返回的是token
     *
     * @param request
     * @param response
     * @param chain
     * @param authResult
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain, Authentication authResult) throws IOException, ServletException {

        UserModel userModel = (UserModel) (authResult.getPrincipal());

        //生成token
        String token = TokenUtils.createToken(userModel.getUsername(),
                userModel.getSecret(),
                userModel.getAuthorities());

        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(
                ResponseUtils.success("登录成功", token)));
        out.flush();
        out.close();
    }


    /**
     * 登录请求失败返回的信息
     *
     * @param request
     * @param response
     * @param failed
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException {
        //也可以设置401状态码
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write(new ObjectMapper().writeValueAsString(
                ResponseUtils.failure(failed.getMessage())));
        out.flush();
        out.close();
    }


    @Nullable
    protected String obtainVerificationCode(HttpServletRequest request) {
        return request.getParameter(VERIFICATION_CODE);
    }

  • 可以看到这个过滤器传入的defaultFilterProcessesUrl是过滤的url,这里我传入的是/login,作为登陆的接口
  • 这个类继承自UsernamePasswordAuthenticationFilter,UsernamePasswordAuthenticationFilter的作用就是从request中拿到用户名密码,传到security框架之中,我这里就只是多加了个验证码,这步操作是在attemptAuthentication方法之中进行的
  • 其中security原本使用的UsernamePasswordAuthenticationToken类只有用户名密码,所以只能编写个UserAuthToken类继承其之后再加个验证码:
@Data
public class UserAuthToken extends UsernamePasswordAuthenticationToken {

    private Object verificationcode;

    public UserAuthToken(Object principal, Object credentials, Object verificationcode) {
        super(principal, credentials);
        this.verificationcode = verificationcode;
    }

    public UserAuthToken(Object principal) {
        super(principal, "");
    }

}
  • 注意这里的attemptAuthentication方法中可以抛出AuthenticationException异常,这会导致进入用户登录验证失败的方法之中(也就是unsuccessfulAuthentication方法 ),但是由于UserAuthFilter是继承自一个Filter类,此时还没进入spring容器之中,无法使用依赖注入,无法使用speingboot查询数据库的框架,因此这里只需要做的就是把需要用来验证的数据传入security框架之中即可
  • 在UserAuthFilter中 剩余的的两个方法则是登录请求成功或者失败的回调,失败了很简单,只要返回401状态码或者返回一个登录失败的信息即可,而登录成功则需要返回生成的token,其中需要携带用户名和用户的权限,这个是jjwt包里面的东西,之后会再说
  • 简单来说登录失败进入unsuccessfulAuthentication方法,登录成功进入successfulAuthentication方法,而登录失败的原因一般就是AuthenticationException异常

查询数据库中的用户信息

  • 承接上一步,已经通过登录接口/login将用户名密码以及验证码传到了框架之中,现在需要的就是根据用户名来查询数据库里面的信息了:
@Slf4j
@Component
public class LoginUserDetailsService implements UserDetailsService {

    @Autowired
    private Student student;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //密码需要加密
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        //TODO 如果用户没有被查询到,可以直接抛出 UsernameNotFoundException
        //TODO 数据库查询 获取  用户名 密码 secret 权限 验证码 等信息
        //TODO spring security 框架默认用户名密码,如果使用验证码方式,直接在后台写死一个固定密码,否则会有问题

        //测试环境 写死一个用户信息
        return new UserModel(student.getUsername(), encoder.encode(student.getPassword()),
                student.getSecret(), student.getVerificationcode(), student.getAuthorities());
    }
}
  • 这一步就是从数据库查询用户信息的地方,然后将查询到的信息也传入 springsecurity框架之中
  • 另外验证码一般是存在redis之中,过期了查询不到的话也可以抛出UsernameNotFoundException 异常,它继承于AuthenticationException,抛出的话会进入到 UserAuthFilter中的unsuccessfulAuthentication方法
@Data
public class UserModel extends User {


    private String secret;
    private String verificationcode;

    public UserModel(String username, String password, String secret, String verificationcode,
                     Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
        this.secret = secret;
        this.verificationcode = verificationcode;
    }


}
  • 原本的user没有验证码和用户生成token的secret,在这里就继承security框架中的user类自己写一个

验证登录

  • 用户请求上传的用户信息和从数据库中查询到的用户信息都已经得到了,接下来只要进行比对即可
  • spring security框架中是在DaoAuthenticationProvider类中进行校验,自然这里只能校验密码,所以这里又需要继承后重新写个:
@Slf4j
public class LoginAuthProvider extends DaoAuthenticationProvider {


    public LoginAuthProvider(LoginUserDetailsService loginUserDetailsService) {
        super();
        setUserDetailsService(loginUserDetailsService);//必须设置
    }


    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
                                                  UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

        //todo 还能做一些非空的判断

        UserModel userModel = (UserModel) userDetails;//从数据库查出来的参数
        UserAuthToken userAuthToken = (UserAuthToken) authentication;//登录请求携带的参数

        if (!getPasswordEncoder().matches(userAuthToken.getCredentials().toString(),
                userModel.getPassword()))
            throw new BadCredentialsException("密码错误");

        if (!userAuthToken.getVerificationcode()
                .equals(userModel.getVerificationcode()))
            throw new BadCredentialsException("验证码错误");

        log.info("验证用户:{},{}", userModel, userAuthToken);
    }
}
  • 除了密码的比对,这里也加上了验证码的比对,其中的BadCredentialsException也是继承于AuthenticationExceptiony如果抛出也会进入unsuccessfulAuthentication方法

spring security配置说明

  • 到这里登录请求的流程基本明确
UserAuthFilter //将/login中的用户名密码传入框架,同时定义了登录成功 和登录失败的方法

LoginUserDetailsService//通过用户名从数据库查询用户信息

LoginAuthProvider //比对上面两个类的信息,校验用户
  • 当然这些类是需要在SecurityConfig类中进行配置的,完整的代码之前已经贴出,这里就贴一下配置这三个类的地方
  • UserAuthFilter
  .and()
                .addFilterBefore(new UserAuthFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
  • loginUserDetailsService 和 LoginAuthProvider 是一起的
 @Autowired
private LoginUserDetailsService loginUserDetailsService;


@Bean
public LoginAuthProvider getLoginAuthProvider() {
        //采用该方式初始化,在LoginAuthProvider中除了构造函数之外可以依赖注入
        return new LoginAuthProvider(loginUserDetailsService);
 }

.and()
.authenticationProvider(getLoginAuthProvider())
  • 到这里,登录请求使用jwt的已经完成

普通请求

  • 在进行完登录请求之后,获得了token,普通的请求需要带上token请求,因为原本的security框架使用的session,自然普通token请求的验证也需要自己来编写
  • 首先编写的是授权未通过时候的操作
  .and()
                .exceptionHandling()
                //未授权处理
                .authenticationEntryPoint(new UnAuthorizedEntryPoint())
public class UnAuthorizedEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        int code = AuthExceptionCode.getCode(authException.getMessage());
        String reason = AuthExceptionCode.getReason(authException.getMessage());
        if (code == 0) reason = authException.getMessage();

        WriteUtils.writeJson(response, ResponseUtils.failure(code, reason, null));
    }

}
  • 这里我自己定义了几个code根据不同情况返回,一般直接在这里返回401状态码就好了
  • commence方法中有传入AuthenticationException参数,这是普通授权失败时候抛出的异常,可以从中获得自己传入信息
  • 接下来这块就是普通请求的授权过程了
 //配置请求的权限
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/test").permitAll()

                //需要特定用户的权限
                .antMatchers("/test3").access(
                        "@AuthService.role('bigboss',request)")
                //普通的请求
                .anyRequest().access("@AuthService.auth(request)")
  • 这一部分使用的是权限表达式
  • permitAll方法因为是无需授权的请求,所以用原来的.permitAll()也行,但是需要用户权限的不能使用原本的.hasRole()以及.authenticated()方法,因为这两个方法无法验证token的有效性,就会出现问题
  • 首先需要写个类:
@Slf4j
@Component(value = "AuthService")
public class AuthService {


    @Autowired
    private Student student;
    

    /**
     * 普通请求认证
     *
     * @param request
     * @return
     * @throws AuthenticationException
     */
    public boolean auth(HttpServletRequest request)
            throws AuthenticationException {
        String token = request.getHeader(TokenUtils.AUTHORIZATION);
        //解析密钥是后台查询的
        Claims claims = TokenUtils.parserToken(token, student.getSecret());
        //用户名
        String username = claims.getSubject();
        UserAuthToken userAuthToken = new UserAuthToken(username);
        //设置Context
        SecurityContextHolder.getContext()
                .setAuthentication(userAuthToken);
        return true;
    }


    /**
     * 单个 用户权限验证
     *
     * @param role
     * @param request
     * @return
     */
    public boolean role(String role, HttpServletRequest request) {
        String token = request.getHeader(TokenUtils.AUTHORIZATION);
        //解析密钥是后台查询的
        Claims claims = TokenUtils.parserToken(token, student.getSecret());

        //用户名
        String username = claims.getSubject();
        //权限
        List<LinkedHashMap<String, String>> authorities =
                (List<LinkedHashMap<String, String>>) (claims.get("authorities"));

        boolean hasRole = false;
        for (LinkedHashMap<String, String> authority : authorities) {
            if (authority.get("authority").equals(role)) {
                hasRole = true;
                break;
            }
        }

        if (!hasRole) throw new BadCredentialsException("没有访问该接口的权限");


        UserAuthToken userAuthToken = new UserAuthToken(username);
        SecurityContextHolder.getContext()
                .setAuthentication(userAuthToken);
        return true;
    }


}
  • 其中@Component(value = "AuthService")很重要
  • 两个方法简单来说就是获取request中的token进行解析,如果有问题或者失效了,就抛出AuthenticationException异常就会到UnAuthorizedEntryPoint中返回给前台,最后return true了就ton过了校验
  • 注意需要使用 SecurityContextHolder.getContext().setAuthentication()设置用户信息,这样就可以在接口中获取到,一般来说只要放入用户名即可
  • 另外生成token的secret是存在数据库中的,解析的时候需要进行查询,如果需要将尚未失效的token作废,可以设计一个接口废弃原本的secret然后生成新的secret也是一种思路

token生成与解析

  public static String createToken(String username, String secret,
                                     Collection<GrantedAuthority> authorities) {
        return Jwts.builder()
                .setSubject(username)
                .claim(AUTHORITIES, authorities)//配置用户权限(角色)
                .setIssuedAt(DateUtils.createDate()) //设置token发布时间
                .setExpiration(DateUtils.expirationDate(TIME_OUT))//设置过期时间
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }



    public static Claims parserToken(String token, String secret) throws AuthenticationException {
        try {
            return Jwts.parser().setSigningKey(secret)
                    .parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            throw new BadCredentialsException(AuthExceptionCode.EXPIRED.getCodeValue());
        } catch (IllegalArgumentException e) {
            throw new BadCredentialsException(AuthExceptionCode.EMPTY.getCodeValue());
        } catch (SignatureException e) {
            throw new BadCredentialsException(AuthExceptionCode.SIGN.getCodeValue());
        } catch (MalformedJwtException e) {
            throw new BadCredentialsException(AuthExceptionCode.MALFORMED.getCodeValue());
        } catch (UnsupportedJwtException e) {
            throw new BadCredentialsException(AuthExceptionCode.UNSUPPORTED.getCodeValue());
        }catch (Exception e){
            throw new BadCredentialsException(AuthExceptionCode.UNKNOW.getCodeValue());
        }
    }

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

推荐阅读更多精彩内容