目录

1. 环境

2. 实现步骤

2.1 集成Redis

2.2 实现Vue登录页面

2.3 实现Spring Boot后端

2.3.1 用户认证

2.3.2 生成令牌

2.3.3 验证令牌

2.4 实现单点登录

2.4.1 登录流程


单点登录(Single Sign-On,SSO)是指在多个应用系统中,用户只需登录一次就可以访问多个应用系统的能力。下面将介绍如何使用Vue和Spring Boot实现单点登录。

1. 环境
  • JDK 1.8+
  • Spring Boot 2.x
  • Vue.js 2.x
  • Redis
2. 实现步骤
2.1 集成Redis

首先需要安装Redis以及Java的Redis客户端,这里使用Jedis作为Redis客户端。在Spring Boot项目中集成Redis,需要添加以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.3.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.3.0</version>
</dependency>

然后在application.properties中配置Redis连接信息:

spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
2.2 实现Vue登录页面

在Vue项目中,可以使用Axios发送登录请求,获取服务端返回的令牌,并将令牌保存到Cookie中:

// 登录
login() {
    let params = new URLSearchParams();
    params.append('username', this.username);
    params.append('password', this.password);
    axios.post('/api/login', params).then((response) => {
        console.log(response);
        if (response.data.code === 200) {
            // 保存令牌
            let token = response.data.token;
            Cookie.set('token', token, { expires: 1 });
            // 跳转到首页
            this.$router.push('/home');
        } else {
        	// 登录失败
            this.$message.error(response.data.msg);
        }
    }).catch((error) => {
        console.log(error);
    });
}
// 登录
login() {
    let params = new URLSearchParams();
    params.append('username', this.username);
    params.append('password', this.password);
    axios.post('/api/login', params).then((response) => {
        console.log(response);
        if (response.data.code === 200) {
            // 保存令牌
            let token = response.data.token;
            Cookie.set('token', token, { expires: 1 });
            // 跳转到首页
            this.$router.push('/home');
        } else {
        	// 登录失败
            this.$message.error(response.data.msg);
        }
    }).catch((error) => {
        console.log(error);
    });
}
2.3 实现Spring Boot后端

服务端使用Spring Boot实现单点登录,需要实现以下几个步骤:

2.3.1 用户认证

用户登录后,服务端需要进行用户认证。这里使用Spring Security进行用户认证,需要添加以下依赖:

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

然后在WebSecurityConfigurerAdapter中进行配置:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginProcessingUrl("/api/login").permitAll()
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":200,\"msg\":\"登录成功\"}");
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":401,\"msg\":\"" + exception.getMessage() + "\"}");
                        out.flush();
                        out.close();
                    }
                })
                .and()
                .logout()
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":200,\"msg\":\"退出登录成功\"}");
                        out.flush();
                        out.close();
                    }
                })
                .deleteCookies("JSESSIONID", "SESSION");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }
}
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginProcessingUrl("/api/login").permitAll()
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":200,\"msg\":\"登录成功\"}");
                        out.flush();
                        out.close();
                    }
                })
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":401,\"msg\":\"" + exception.getMessage() + "\"}");
                        out.flush();
                        out.close();
                    }
                })
                .and()
                .logout()
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.setContentType("application/json;charset=utf-8");
                        PrintWriter out = response.getWriter();
                        out.write("{\"code\":200,\"msg\":\"退出登录成功\"}");
                        out.flush();
                        out.close();
                    }
                })
                .deleteCookies("JSESSIONID", "SESSION");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }
}

其中,UserDetailsServiceImpl实现UserDetailsService接口,从数据库中获取用户信息:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(user.getRoles().split(","));

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
    }
}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(user.getRoles().split(","));

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
    }
}

PasswordEncoder使用BCryptPasswordEncoder,对用户密码进行加密:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
2.3.2 生成令牌

用户登录成功后,服务端需要生成令牌,并将令牌存储到Redis中。这里使用JWT作为令牌生成工具,需要添加以下依赖:

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

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

然后在JwtUtil中实现令牌生成方法:

public String generateToken(String username) {
    Date now = new Date();
    Date expiration = new Date(now.getTime() + expirationTime * 1000);

    return Jwts.builder()
            .setClaims(new HashMap<>())
            .setSubject(username)
            .setIssuedAt(now)
            .setExpiration(expiration)
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
}

public String generateToken(String username) {
    Date now = new Date();
    Date expiration = new Date(now.getTime() + expirationTime * 1000);

    return Jwts.builder()
            .setClaims(new HashMap<>())
            .setSubject(username)
            .setIssuedAt(now)
            .setExpiration(expiration)
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
}
2.3.3 验证令牌

用户访问其他应用系统时,需要验证用户是否已登录,这里使用Filter实现令牌验证。定义一个AuthFilter,实现doFilter方法:

public class AuthFilter extends OncePerRequestFilter {

    private static final String AUTH_HEADER = "Authorization";

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader(AUTH_HEADER);
        if (StringUtils.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            Claims claims = null;
            try {
                claims = jwtUtil.parseToken(token);
            } catch (Exception e) {
                e.printStackTrace();
            }
            if (claims != null && claims.getExpiration().after(new Date())) {
                String username = claims.getSubject();
                // 验证用户是否已登录
                if (StringUtils.isNotBlank(username) && JedisUtil.exists(username)) {
                    filterChain.doFilter(request, response);
                    return;
                }
            }
        }
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write("{\"code\":401,\"msg\":\"请先登录\"}");
        out.flush();
        out.close();
    }}

public class AuthFilter extends OncePerRequestFilter {

    private static final String AUTH_HEADER = "Authorization";

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader(AUTH_HEADER);
        if (StringUtils.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            Claims claims = null;
            try {
                claims = jwtUtil.parseToken(token);
            } catch (Exception e) {
                e.printStackTrace();
            }
            if (claims != null && claims.getExpiration().after(new Date())) {
                String username = claims.getSubject();
                // 验证用户是否已登录
                if (StringUtils.isNotBlank(username) && JedisUtil.exists(username)) {
                    filterChain.doFilter(request, response);
                    return;
                }
            }
        }
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        out.write("{\"code\":401,\"msg\":\"请先登录\"}");
        out.flush();
        out.close();
    }}

然后在WebSecurityConfigurerAdapter中添加AuthFilter:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthFilter authFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
    	// ...
    }
}
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthFilter authFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/api/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class)
    	// ...
    }
}
2.4 实现单点登录

实现了用户认证、生成令牌和验证令牌之后,就可以实现单点登录了。

2.4.1 登录流程

用户在一个应用系统中登录,然后访问其他应用系统时,其他应用系统需要验证用户是否已登录。这里的步骤如下:

  1. 用户在第一个应用系统中登录,该系统使用单点登录技术生成一个令牌,并将该令牌返回给用户。
  2. 用户在访问其他应用系统时,在 HTTP 请求中添加该令牌。
  3. 接收请求的应用系统将该令牌发送到单点登录服务器进行验证。
  4. 单点登录服务器验证该令牌,如果验证通过,则说明用户已登录,应用系统可以允许用户访问该系统的资源。
  5. 如果验证未通过,则说明用户未登录或者登录已过期,应用系统需要引导用户重新登录或者跳转到登录页面。
  6. 用户在其他应用系统中的操作会触发新的 HTTP 请求,其中仍然需要携带该令牌进行身份验证。