上一节,我们讲述了Oauth2.0 配合security的使用,配合redis去存token,或者配合Jwt去存token。这篇文章,我们主要来系统的串起来讲一下,从宏观的层面来讲述一下单点登录,并且来实现一个demo。
首先,我们来借助一个真实的案例来切入:
相信大家都登录过码云吧:(https: //gitee.com/)
在登录选项里我们选择使用三方账号进行登录(QQ)
然后他就会跳转到下面这个地址:
这个地址有没有很熟悉?
路径里面的几个参数是不是很熟悉?client_id,redirect_uri,response_type
上面一节课我们了解了这几个概念: 认证服务器,客户端。这里我们进行对号入座:
认证服务器:就是上面的跳转地址(QQ的认证服务器)
客户端:码云
上面的认证就是授权码模式进行认证,登陆QQ后,QQ的认证服务器通过你的认证,返回授权码和回调地址(redirect_uri):码云拿到授权码进行令牌(token)的获取
通过检查调用的接口情况,发现码云根据上面的callback接口获取到code,然后在接口内部去调用QQ的获取token的接口(默认的是 /ooauth/token)的接口。这样的话进行就能够拿到token了,拿到token后可以进行重定向到login页面,请求QQ那边的获取用户基本信息的接口(资源)来获取QQ的基本信息(昵称,用户名)来回填到码云的登录表单中。
这样我们就基本了解什么是单点登录了。
下面我们写一个demo:
1. 新建一个服务(就好比QQ的认证服务)
依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.starzyn</groupId>
<artifactId>sso</artifactId>
<version>0.0.1</version>
<name>sso</name>
<description>sso</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>2.2.3.RELEASE</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
<version>10.10.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.swagger</groupId>
<artifactId>swagger-annotations</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.5.7</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
配置文件:
server:
port: 9989
spring:
datasource:
url: jdbc:mysql://your-server:23306/sso?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: tiger
driver-class-name: com.mysql.cj.jdbc.Driver
cloud:
nacos:
discovery:
server-addr: http://39.102.83.104:8848
feign:
okhttp:
enabled: true
用户实体类:
package com.starzyn.sso.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import java.time.LocalDateTime;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* <p>
* 后台用户表
* </p>
*
* @author starzyn
* @since 2021-02-23
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("ums_admin")
@ApiModel(value="UmsAdmin对象", description="后台用户表")
public class UmsAdmin implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String username;
private String password;
@ApiModelProperty(value = "头像")
private String icon;
@ApiModelProperty(value = "邮箱")
private String email;
@ApiModelProperty(value = "昵称")
private String nickName;
@ApiModelProperty(value = "备注信息")
private String note;
@ApiModelProperty(value = "创建时间")
private LocalDateTime createTime;
@ApiModelProperty(value = "最后登录时间")
private LocalDateTime loginTime;
@ApiModelProperty(value = "帐号启用状态:0->禁用;1->启用")
private Integer status;
}
security认证使用的用户类:
package com.starzyn.sso.entity;
import cn.hutool.crypto.digest.BCrypt;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.*;
import static cn.hutool.crypto.digest.BCrypt.hashpw;
/**
* @author starzyn
* @className:SecurityUser
* @date : 2021/2/23
* @description:
*/
public class SecurityUser implements UserDetails {
private Long id;
private String username;
private String password;
private Collection<SimpleGrantedAuthority> authorities;
private boolean enabled;
public SecurityUser(){}
public SecurityUser(UmsAdmin admin){
this.id = admin.getId();
this.username = admin.getUsername();
this.password = admin.getPassword();
this.authorities = new HashSet<>();
Arrays.stream(admin.getNote().split(",")).forEach(role -> this.authorities.add(new SimpleGrantedAuthority(role)));
this.enabled = Objects.equals(1, admin.getStatus()) ? true : false;
}
// public static void main(String[] args) {
// System.out.println(BCrypt.hashpw("123456"));
//
// }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
用户信息的业务层:
package com.starzyn.sso.api.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.starzyn.sso.entity.SecurityUser;
import com.starzyn.sso.entity.UmsAdmin;
import com.starzyn.sso.mapper.UmsAdminMapper;
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 org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* @author starzyn
* @className:UserService
* @date : 2021/2/23
* @description:
*/
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
private UmsAdminMapper umsAdminMapper;
@Override
public UserDetails loadUserByUsername(String username) {
List<UmsAdmin> users = umsAdminMapper.selectList(new QueryWrapper<UmsAdmin>().eq("username", username));
if (CollectionUtils.isEmpty(users)) {
throw new UsernameNotFoundException("用户名不存在");
}
return new SecurityUser(users.get(0));
}
}
认证服务配置:
package com.starzyn.sso.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.AccessTokenConverter;
import org.springframework.security.oauth2.provider.token.TokenStore;
/**
* @author starzyn
* @className:AuthServer
* @date : 2021/2/23
* @description:
*/
@Configuration
@EnableAuthorizationServer
public class AuthServer extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserDetailsService userServiceImpl;
@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;
@Autowired
private AccessTokenConverter jwtAccessTokenConverter;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("portal")
.authorizedGrantTypes("authorization_code", "password", "refresh_token")
.redirectUris("http://localhost:8080/callback")
.secret(passwordEncoder.encode("admin"))
.scopes("all");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userServiceImpl)
.tokenStore(tokenStore)
.accessTokenConverter(jwtAccessTokenConverter);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();//这个地方需要注意一下,如果不进行配置,就会抱401的问题,通过跟源码发现,如果不配置,他就会使用你前面进行认证的那个 Authentication对象,这样的话就会需要加头,进行认证,用postman进行请求就是可以的,但是使用restTemplate 请求就会抱401
}
}
security的配置:
package com.starzyn.sso.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
/**
* @author starzyn
* @className:SpringSecurityConfig
* @date : 2021/2/23
* @description:
*/
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/oauth/**", "/rsa/publicKey", "/v2/api-docs", "/callback")
.permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.permitAll();
}
}
Jwt的配置
package com.starzyn.sso.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
@Configuration
public class JwtTokenStoreConfig {
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setSigningKey("starzyn");
return jwtAccessTokenConverter;
}
}
2. 新建一个客户端(就好比码云)
新起一个服务(端口8080)
测试接口:
package com.starzyn.client1.api.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Base64Utils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
public class UserController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("callback")
public String getToken(@RequestParam(required = false) String code) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code", code);
params.add("client_id", "portal");
params.add("client_secret", "admin");
params.add("redirect_uri", "http://localhost:8080/callback");
// HttpHeaders httpHeaders = new HttpHeaders();
// httpHeaders.add("Content-type", "application/x-www-form-urlencoded");
// httpHeaders.add("Authorization", "Basic " + Base64Utils.encodeToString("portal:admin".getBytes()));
// HttpEntity<MultiValueMap> entity = new HttpEntity<>(httpHeaders, params);
ResponseEntity<String> resp = restTemplate.postForEntity("http://localhost:9989/oauth/token", params, String.class);
return resp.getBody();
}
}
3. 测试
启动两个服务,访问 http: //localhost:9989/oauth/authorize?response_type=code&client_id=portal&redirect_uri=http: //localhost:8080/callback&scope=all
这个页面就是类比跳转到QQ的认证服务,进行QQ的登陆
输入用户名和密码之后
这就好比QQ对码云的授权
点击Authorize之后跳转到 http: //localhost:8080/callback?code=1MWurK
这就是码云拿到了QQ认证服务给的token
整个流程走下来,token拿到了,其他的就是去资源服务器上去访问资源了(请求QQ的获取用户的接口)
整个流程走下来,就是一个根据授权码模式来进行单点登录的方式了。