Spring Security 开发2

接着上文,谈到了表单登录和手机验证码登录,有时候我们需要使用第三方登录,那么这个和 SS 如何结合呢?

OAth

OAuth简介
我们为了使用 QQ、微信、微博用户名和密码来获取用户,简单的说可以使用用户的用户名和密码进入第三方系统,然后抓取信息。泄漏自私的账号密码这样的事用户肯定不干,那么如何让用户既不泄漏自己的密码又能把自己的第三方账号的某些信息提供到我们系统呢?这就得使用 OAuth,他使用的是一个 Token 来进行访问第三方系统,第三方提供这个 Token 只能提供部分服务,而且这个 Token 是有时效性的,在使用 Token 之前也是需要用户确认的。

OAuth 的角色和术语
1、Provider:服务提供商,象 QQ、微信等,他们存储了用户的信息,能够提供 Token;
2、Resource Owner:资源所有者,用户的自有数据,数据虽然存放在 QQ 等平台,但数据拥有者是用户;
3、第三方 Client:也就是想要获取QQ等提供商存储的用户信息的这些应用;
4、Authorization Server:认证服务器,认证用户身份并且产生令牌;
5、Resource Server:资源服务器,保留用户数据的地方,验证令牌;

OAuth 流程


image

其中第二步骤同意授权有四种实现:
授权码模式(目前使用最多)、密码模式、客户端模式、简化模式

授权码模式的流程(主流模式)


image

其中第二步是导向服务提供商,防止伪造同意。第四步是服务器端后台进行的。

Spring Social开发第三方登录

流程


image

简单说:通过供应商的账号密码信息之后获取用户基本信息,组装成我们的认证信息,完成登录。
而Spring Social是一个过滤器,完成上面的操作。

Spring Social对应的内部实现分析
ServiceProvider:相当于服务提供商的封装类;
OAuth2Operations:相当于1-5步骤的通用操作;
API:第6步个性化操作,有的提供商提供3个字段,有点提供5个字段不等;
第7步与提供商没啥关系,只与我们自己的有关,他有如下的接口和类
Connection:封装前六步获取的链接信息,具有固定信息;
ConnectionFactory:生成上面链接的工厂,而创建这些链接,就需要ServiceProvider;由于第六步的每家不同的数据,就需要把不同的数据封装成一个标准的数据,才能构造 Connection,所以还需要ApiAdapter;
另外,平台用户 A 是如何和自己系统用户张三对应上的呢,是在数据库层面有一张userConnection 表进行映射,而操作这个的表的是userConnectionRepository 这个类


image

QQ 登录

根据上面的依赖分析可知,需要首先构造OAuth2Operations和API相关的类。

首先来操作 QQAPI 部分
新建 QQUserInfo 类来封装 QQ 用户信息;
新建 QQ 接口,提供一个方法,返回 QQUserInfo;
新建 QQ 接口的实现 QQImpl,该类继承 AbstractOAuth2ApiBinding 和实现 QQ 接口;

查看AbstractOAuth2ApiBinding源码可以发现,需要注意是多实例对象。


image

接下来我们查阅下如何访问 QQ 用户信息的 API 文档


QQ 互联

请求说明
参数说明
返回参数

根据返回参数构建QQUserInfo属性字段,省略

然后开始编写 QQImpl 的具体实现

public class QQImpl extends AbstractOAuth2ApiBinding implements QQ{

    //获取 openID 的 URL
    private static final String URL_GET_OPEN_ID="https://graph.qq.com/oauth2.0/me?access_token=%s";
    
    //获取用户信息的 URL
    //https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
    //AccessToken 参数交给父类处理,这里不用挂载
    private static final String URL_GET_USER_INFO="https://graph.qq.com/user/get_user_info?&oauth_consumer_key=%s&openid=%s";
    
    private String appId;
    
    private String openId;
    
    public QQImpl(String accessToken,String appId) {
        //如果是一个参数的话,会把accessToken塞到请求头,我们需要的是挂载的 URL 后面,所以这里是2个参数的父构造
        super(accessToken,TokenStrategy.ACCESS_TOKEN_PARAMETER);
        this.appId=appId;
        //openid 需要请求后才能获取
        String url=String.format(URL_GET_OPEN_ID, accessToken);
        String result=super.getRestTemplate().getForObject(url, String.class);
        //从返回字符串里面进行截取
        this.openId=StringUtils.substringBetween(result, "\"openId\":", "}");
    }
    
    @Override
    public QQUserInfo getQQUserInfo() {
        String url=String.format(URL_GET_USER_INFO, appId,openId);
        String result=super.getRestTemplate().getForObject(url, String.class);
        System.out.println(result);
        ObjectMapper om=new ObjectMapper();
        QQUserInfo qqUserInfo=null;
        try {
            qqUserInfo=om.readValue(result, QQUserInfo.class);
            qqUserInfo.setOpenId(openId);
            return qqUserInfo;
        } catch (Exception e) {
            throw new RuntimeException("获取用户失败");
        } 
    }

}

到这里,完成了提供商的 API 操作。

接下来 OAuth2Operations 我们采用默认实现 OAuth2Template 来进行,紧跟着就开始构造 ServiceProvider 对象,新建 QQServiceProvider

public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ>{

    private String appId;
    
    private static final String authorizeUrl="https://graph.qq.com/oauth2.0/authorize";
    
    private static final String accessTokenUrl="https://graph.qq.com/oauth2.0/token";
    
    
    public QQServiceProvider(String appId,String appSecret) {
        //authorizeUrl 第一步的 URL
        //accessTokenUrl 第四步的 URL
        super(new OAuth2Template(appId, appSecret, authorizeUrl, accessTokenUrl));
        
    }

    @Override
    public QQ getApi(String accessToken) {
        return new QQImpl(accessToken,appId);
    }

}

到这里,右边提供商方面的代码完成,接下来完成左边部分的代码。
首先看到左边包含最里面的类是一个适配器类,把 QQ 用户数据包装成 Connection 通用数据,先从这开始,构建 QQAdapter

public class QQAdapter implements ApiAdapter<QQ>{

    @Override
    public boolean test(QQ api) {
        // 测试 QQ 服务是否可用,这里假设可用
        return true;
    }

    @Override
    public void setConnectionValues(QQ api, ConnectionValues values) {
        //把提供商提供的个性化数据包装成 Connect 通用的数据格式
        QQUserInfo userInfo = api.getQQUserInfo();
        
        values.setDisplayName(userInfo.getNickname());//设置显示名称
        values.setImageUrl(userInfo.getFigureurl_qq_1());//设置图像
        values.setProfileUrl(null);//个人主页
        values.setProviderUserId(userInfo.getOpenId());//提供商给用户的唯一标识
    }

    @Override
    public UserProfile fetchUserProfile(QQ api) {
        return null;
    }

    @Override
    public void updateStatus(QQ api, String message) {
        // 发条消息更新状态
    }

}

有了 ServiceProvider 和 QQAdapter 就可以构造 Connectionfactory了。

public class QQConectionFactory extends OAuth2ConnectionFactory<QQ>{

    public QQConectionFactory(String providerId, String appId,String appSecret) {
        super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());
    }

}

有了ConectionFactory就有了 Connection,然后需要UsersConnectionRepository,这个可以使用默认的 JdbcUsersConnectionRepository,只需要配置即可

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter{

    @Autowired private DataSource dataSource;
    /**
     * 
     * 需要预先建立表
     * create table UserConnection (userId varchar(255) not null,
        providerId varchar(255) not null,
        providerUserId varchar(255),
        rank int not null,
        displayName varchar(255),
        profileUrl varchar(512),
        imageUrl varchar(512),
        accessToken varchar(512) not null,
        secret varchar(512),
        refreshToken varchar(512),
        expireTime bigint,
        primary key (userId, providerId, providerUserId));
        create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
     * 
     */
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        //参数1是数据源
        //参数2是链接工厂,可能有多个(QQ、微信),会找到自己需要的
        //参数3是加密形式,这些数据敏感
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }
}

以此同时,我们的UserDetailService 也需要增加社交登录实现

@Component("userDetailsService")
public class CustomerUserDetailService implements UserDetailsService,SocialUserDetailsService{

    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //根据用户名去数据库查询用户信息
        //可以注入 jdbc,mybatis 等 DAO 
        //这里方便演示,直接在代码里面做了
        System.out.println("=======表单登录========="+username);
        //User 对象已经实现了UserDetails
        //AuthorityUtils.commaSeparatedStringToAuthorityList 方法是以逗号分割产生一个授权集合
        User user=new User(username, 
                passwordEncoder.encode("123456"), //其实是 DB 存的加密密码
                true,//账号可用
                true,//账号不过期
                true,//密码不过期
                true,//账号没有锁定
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
        return user;
    }

    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        System.out.println("======社交用户登录=========="+userId);
        SocialUser user=new SocialUser(userId, 
                passwordEncoder.encode("123456"), //其实是 DB 存的加密密码
                true,//账号可用
                true,//账号不过期
                true,//密码不过期
                true,//账号没有锁定
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
        return user;
    }

}

我们还需要配置一些 QQ 登录的基本信息,新建 QQProperties

public class QQProperties extends SocialProperties{

    private String providerId="qq";

    public String getProviderId() {
        return providerId;
    }

    public void setProviderId(String providerId) {
        this.providerId = providerId;
    }
}

并且把QQProperties设置到总的配置类中,以便调用。

有了这个配置,我们就可以构造 QQConnectionFactory 了,为了得到这个工厂,我们需要新建一个配置类

@Configuration
@ConditionalOnProperty("cn.ts.qq")//希望有配置才起作用
public class QQConfig extends SocialAutoConfigurerAdapter{

    @Autowired private SecurityProperties securityProperties;
    
    @Override
    protected ConnectionFactory<?> createConnectionFactory() {
        
        return new QQConectionFactory(
                securityProperties.getQq().getProviderId(), 
                securityProperties.getQq().getAppId(),
                securityProperties.getQq().getAppSecret()
                );
    }

}

为了能够把社交登录布置到过滤器链上,需要配置一个SpringSocialConfigurer到 http
直接在SocialConfig里面增加一个方法,返回SpringSocialConfigurer

@Bean
    public SpringSocialConfigurer qqSocialConfigurer(){
        return new SpringSocialConfigurer();
    }

在WebSecurityConfig里面注入,并且配置到HttpSecurity http这个对象上


image

页面增加 QQ 登录

<h3>社交登录</h3>
<a href="/auth/qq">QQ 登录</a>

其中“/auth/”开头的请求都会被SocialAuthenticationFilter 拦截;后面的qq 是 providerId

问题

由于我们的表里面需要钱三个字段组合成复合组件,我们现在只有后2两个 ID,没有我们自己系统的 ID,这就需要引导用户到注册界面。童年故事我们查看SocialAuthenticationProvider源码也发现

image

首先验证是不是SocialAuthenticationToken,然后通过他拿到 Connection,再通过 Connection 拿到userId,然后根据 userId 获取UserDetails,最后重新构造一个新的SocialAuthenticationToken返回。
我们注意到,一旦userId 为空,就会报出BadCredentialsException异常,而处理这个异常的过滤器是SocialAuthenticationFilter,可以查看这个源码。
image

这个异常被捕获之后的处理逻辑如上代码,可以发现,需要配置一个注册入口。

新建一个注册页面,配置 properties 属性,配置权限入口


image

image

image

为了更好的体验,需要在注册页面显示 QQ 用户信息,或者用户注册后,需要绑定相应的信息,需要一个工具类ProviderSignInUtils,该工具类构造方法需要两个参数

public ProviderSignInUtils( ConnectionFactoryLocator connectionFactoryLocator,UsersConnectionRepository connectionRepository) {
        this(new HttpSessionSessionStrategy(),connectionFactoryLocator,connectionRepository);
    }

第一个参数,SpringBoot 已经给我们注册好了,直接注入使用,第二可以在 SocialConfig 里面看到


image

所以直接在这个类里面构造一个 Bean,作为其他的地方的注入即可

整个流程如下图


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

推荐阅读更多精彩内容