SpringSecurity使用JWT实现单点登录

280 阅读3分钟

SpringSecurity使用JWT实现单点登录-MY

流程

jwt流程图

父工程pom.xml

	
	<packaging>pom</packaging>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.7.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>
  <properties>
    <java.version>1.8</java.version>
    <spring-cloud-alibaba.version>2.2.2.RELEASE</spring-cloud-alibaba.version>
    <spring-cloud.version>Hoxton.SR9</spring-cloud.version>
    <lombok.version>1.18.8</lombok.version>
    <mysql.version>5.1.49</mysql.version>
    <mybatis-plus.version>3.4.0</mybatis-plus.version>
    <swagger.version>2.9.2</swagger.version>
  </properties>

  <modules>
    <module>tnqf-auth</module>
  </modules>

  <dependencies>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>${lombok.version}</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.12.0</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/commons-beanutils/commons-beanutils -->
    <dependency>
      <groupId>commons-beanutils</groupId>
      <artifactId>commons-beanutils</artifactId>
      <version>1.9.4</version>
    </dependency>

  </dependencies>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>${swagger.version}</version>
      </dependency>
      <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>${swagger.version}</version>
      </dependency>
      <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>${mybatis-plus.version}</version>
      </dependency>
      <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>${mysql.version}</version>
      </dependency>
      <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
        <optional>true</optional>
      </dependency>
      <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-dependencies</artifactId>
        <version>${spring-cloud.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-dependencies</artifactId>
        <version>${spring-cloud-alibaba.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
          <encoding>UTF-8</encoding>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

子工程pom.xml

    <dependencies>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

    </dependencies>

SwaggerConfig

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@EnableSwagger2
@Configuration
public class SwaggerConfig {
    @Bean
    public Docket getUserDocket(){
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("auth认证测试")
                .description("永无BUG")
                .build();
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.example.yjxluser.controller"))
                .paths(PathSelectors.any())
                .build();
    }
}

apiInfo的title,description是在swagger-ui.html上显示的。

apis(RequestHandlerSelectors.basePackage("com.example.yjxluser.controller")),接口扫描,处理指定的Controller。

.paths(PathSelectors.any()),匹配在controller package中的所有url。

Spring Security概览

在Spring Security中,通过将FilterChainProxy嵌入到SpringMVC的过滤器链达到过滤目的。

FilterChainProxy管理Spring Security中所有默认过滤器。

请求到达FilterChainProxy之后,根据Filter的优先级匹配,匹配哪个用哪个处理。

JSON登录

UsernamePasswordAuthenticationFilter登录

UsernamePasswordAuthenticationFilter属于过滤器链的一环,此过滤器的作用是对前端传递过来的用户名密码进行拦截解析,但是只拦截指定的url,例如“/login”。由于默认仅支持form表单登录,所以需要自定义一个可以接收json的UsernamePasswordAuthenticationFilter,以此来满足前后端分离的需求。

MyUsernamePasswordAuthenticationFilter.java

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            // 判断前端传递的是否是json
            if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)){
                Map<String, String> loginData = new HashMap<>();
                try {
                    loginData = new ObjectMapper().readValue(request.getInputStream(), HashMap.class);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                // 通过UsernamePasswordAuthenticationFilter.getUsernameParameter()获取key名称
                String username = loginData.get(getUsernameParameter()).trim();
                String password= loginData.get(getPasswordParameter());
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                this.setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            }else {
                return super.attemptAuthentication(request,response);
            }
        }
    }
}

过滤器的指定需要在SecurityConfig(自定义的)中指定,SecurityConfig继承自WebSecurityConfigurerAdapter。

SecurityConfig.java

import com.example.yjxluser.filter.MyUsernamePasswordAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        // 放行swagger 资源
        http.authorizeRequests()
                .antMatchers("/swagger-ui.html",
                        "/swagger-resources/**",
                        "/webjars/**",
                        "/v2/**",
                        "/api/**")
                .permitAll();

        http.authorizeRequests()
                .anyRequest().authenticated();
        
        // 替换过滤器
        http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.csrf().disable();
        http.cors().disable();
    }

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

    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() throws Exception {
        MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter();
        myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/login_handler");
        myUsernamePasswordAuthenticationFilter.setUsernameParameter("username");
        myUsernamePasswordAuthenticationFilter.setPasswordParameter("password");
        myUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        return myUsernamePasswordAuthenticationFilter;
    }
}

替换了UsernamePasswordAuthenticationFilter之后,默认的form登录会失效,失效的属性可以在MyUsernamePasswordAuthenticationFilter中重新配置,必须用setAuthenticationManager()方法配置AuthenticationManager,否则启动报错。

UserController

    @RequestMapping("/login_handler")
    public void loginHandler(){
    }

此处/login_handler不需要处理逻辑,登录逻辑在UserDetailsService中处理,成功登录之后再AuthenticationSuccessHandler中处理。

JWT

application.yml

my:
  jwt:
    expire: 604800
    secret: qaz9988
    header: Authorization

JWTUtils.java

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.Date;

@Data
@Component
@ConfigurationProperties(prefix = "my.jwt")
public class JwtUtils {
    private long expire;
    private String secret;
    private String header;

    public String generateToken(String username){
        Date nowDate = new Date();
        Date expireDate = new Date(nowDate.getTime() + 1000 * expire);
        return Jwts.builder()
                .setHeaderParam("type","JWT")
                .setSubject(username)
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512,secret)
                .compact();
    }
    // 解析JWT
    public Claims getClaimsByToken(String jwt) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(jwt)
                    .getBody();
        } catch (Exception e) {
            return null;
        }
    }

    // 判断JWT是否过期
    public boolean isTokenExpired(Claims claims) {
        return claims.getExpiration().before(new Date());
    }

}

UserDetailsService

UserDetailsService使用loadUserByUsername方法查询数据库返回UserDetails,UserDetailsServiceImpl实现UserDetailsService。

import com.example.yjxluser.entity.MyUserDetails;
import com.example.yjxluser.mapper.UserInfoMapper;
import com.example.yjxluser.mapper.UserPasswordMapper;
import com.example.yjxluser.service.model.UserModel;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserInfoMapper userInfoMapper;
    @Autowired
    private UserPasswordMapper userPasswordMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserModel userModel = new UserModel();
        return Optional.ofNullable(userInfoMapper.getByUsername(username))
                .map(u->{
                    BeanUtils.copyProperties(u,userModel);
                    return userModel;
                })
                .flatMap(um->Optional.ofNullable(userPasswordMapper.getByUid(um.getId())))
                .map(p->{
                    userModel.setPassword(p.getPassword());
                    return MyUserDetails.builder()
                            .username(userModel.getUsername())
                            .password(userModel.getPassword())
                            .build();
                })
                .orElse(null);
    }
}

UserModel

核心领域模型

import com.example.yjxluser.entity.Contact;
import com.example.yjxluser.entity.Roles;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserModel {
    private Long id;
    private String username;
    private String trueName;
    private java.sql.Timestamp createName;
    private Long enable;
    private String phoneNumber;
    private Long gender;
    private java.sql.Timestamp birthday;
    private String location;
    private String password;
    private String salt;
    private List<Roles> roles;
    private List<Contact> contacts;
}

UserDetails

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MyUserDetails implements UserDetails {
    private String username;
    private String password;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

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

    @Override
    public String getUsername() {
        return this.username;
    }

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

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

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

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

JWTAuthenticationFilter

import com.example.yjxluser.mapper.UserInfoMapper;
import com.example.yjxluser.mapper.UserPasswordMapper;
import com.example.yjxluser.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


public class JWTAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private UserPasswordMapper userPasswordMapper;
    @Autowired
    private UserInfoMapper userInfoMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String jwt = request.getHeader(jwtUtils.getHeader());
        if (StringUtils.isBlank(jwt)){
            chain.doFilter(request,response);
            return;
        }

        // 解析前端传递jwt
        Claims claimsByToken = jwtUtils.getClaimsByToken(jwt);

        // 若为空则无法解析,jwt是被篡改了
        if (claimsByToken==null){
            throw new JwtException("异常");
        }
        if (jwtUtils.isTokenExpired(claimsByToken)){
            throw new JwtException("jwtToken过期");
        }

        // 获取jwt解析的用户名
        String username = claimsByToken.getSubject();
        // 没权限也要必须设置为null,不能不填,否则认证失败
        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(
                        username,
                        userPasswordMapper.getByUid(userInfoMapper.getByUsername(username).getId()),
                        null
                );

        // 将构建的UsernamePasswordAuthenticationToken放入security的上下文
        SecurityContextHolder.getContext().setAuthentication(token);
        chain.doFilter(request,response);
    }
}

SecurityConfig.java

import com.example.yjxluser.filter.JWTAuthenticationFilter;
import com.example.yjxluser.filter.MyUsernamePasswordAuthenticationFilter;
import com.example.yjxluser.utils.JwtUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.PrintWriter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtUtils jwtUtils;
    @Autowired
    private UserDetailsService myUserDetailsService;
    @Autowired
    private PasswordEncoder passwordEncoder;

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

        // 放行swagger 资源
        http.authorizeRequests()
                .antMatchers("/swagger-ui.html",
                        "/swagger-resources/**",
                        "/webjars/**",
                        "/v2/**",
                        "/api/**")
                .permitAll();
        http.authorizeRequests().anyRequest().authenticated();


        // 替换默认UsernamePasswordAuthenticationFilter
        http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // jwt要优先验证
        // 添加jwtAuthenticationFilter在MyUsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // form表单登录必须关闭,否则会登录之后还会要求验证
//        http.authorizeRequests()
//                .anyRequest().authenticated()
//                .and()
//                .formLogin()
//                .loginProcessingUrl("/login_handler")
//                .successForwardUrl("/login_result")
//                .permitAll();

        //关闭session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.csrf().disable();
        http.cors().disable();
    }

    // 设置自定义UserDetailsService和PasswordEncoder
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsService).passwordEncoder(passwordEncoder);
    }

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

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

    @Bean
    public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() throws Exception {
        MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter();

        // 处理登录url
        myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/login_handler");

        // 设置登录成功处理器
        myUsernamePasswordAuthenticationFilter.setAuthenticationSuccessHandler((httpServletRequest, httpServletResponse, authentication) -> {
            PrintWriter writer = httpServletResponse.getWriter();
            writer.write(new ObjectMapper().writeValueAsString(jwtUtils.generateToken(authentication.getName())));
            writer.flush();
            writer.close();
        });

        // 设置获取前端用户名和密码参数名称
        myUsernamePasswordAuthenticationFilter.setUsernameParameter("username");
        myUsernamePasswordAuthenticationFilter.setPasswordParameter("password");

        // 必须设置AuthenticationManager,否则报错。AuthenticationManager管理Authentication。
        myUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        return myUsernamePasswordAuthenticationFilter;
    }
    @Bean
    public JWTAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        return new JWTAuthenticationFilter();
    }
}