文章目录

  • 目标
  • 通用第三方登陆设计
    • 思路
      • 自定义登陆流程
  • 代码实现
    • 核心依赖
    • 创建第三方授权应用
    • 调用第三方平台部分
      • login.html
      • controller 层
      • service 层
      • 核心配置
      • 通用第三方登陆配置
    • 本系统的授权认证部分
    • 认证流程一:已经绑定现有用户流程
      • OtherSysOauth2LoginFilter
      • OtherSysOauth2LoginAuthenticationToken
      • OtherSysOauth2LoginProvider
      • OtherSysOauth2LoginUserDetailsServiceImpl
      • 认证成功事件或者失败事件
    • 认证流程二:没有绑定现有用户流程
      • 绑定用户页面
      • UserBindFilter
      • BindAuthenticationToken
      • BindLoginProvider
      • UserDetailsServiceImpl
    • 核心配置
  • 综上所述

目标

  • 了解JustAuth对接SpringSecurity通用第三方登陆

参考:JustAuth

SpringSecurity实战(五)-认证流程源码分析

查看github账户授予权限的应用

markdown画流程图,流程图语法

上篇已经介绍了第三方登陆相关概念和一些流行框架。

通用第三方登陆设计

需求:参考Gitee 网站的第三方登陆实现流程,将其他第三方平台接入系统,并融入SpringSecurity的管理中。

选用框架:基于Justauth框架的实现,对接Spring Security。(如果不使用Spring Security,只需要使用justauth框架即可。)

登陆流程:

#mermaid-svg-QHXHT4hd2lmKVei5 .label{font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family);fill:#333;color:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .label text{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .node rect,#mermaid-svg-QHXHT4hd2lmKVei5 .node circle,#mermaid-svg-QHXHT4hd2lmKVei5 .node ellipse,#mermaid-svg-QHXHT4hd2lmKVei5 .node polygon,#mermaid-svg-QHXHT4hd2lmKVei5 .node path{fill:#ECECFF;stroke:#9370db;stroke-width:1px}#mermaid-svg-QHXHT4hd2lmKVei5 .node .label{text-align:center;fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .node.clickable{cursor:pointer}#mermaid-svg-QHXHT4hd2lmKVei5 .arrowheadPath{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .edgePath .path{stroke:#333;stroke-width:1.5px}#mermaid-svg-QHXHT4hd2lmKVei5 .flowchart-link{stroke:#333;fill:none}#mermaid-svg-QHXHT4hd2lmKVei5 .edgeLabel{background-color:#e8e8e8;text-align:center}#mermaid-svg-QHXHT4hd2lmKVei5 .edgeLabel rect{opacity:0.9}#mermaid-svg-QHXHT4hd2lmKVei5 .edgeLabel span{color:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .cluster rect{fill:#ffffde;stroke:#aa3;stroke-width:1px}#mermaid-svg-QHXHT4hd2lmKVei5 .cluster text{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family);font-size:12px;background:#ffffde;border:1px solid #aa3;border-radius:2px;pointer-events:none;z-index:100}#mermaid-svg-QHXHT4hd2lmKVei5 .actor{stroke:#ccf;fill:#ECECFF}#mermaid-svg-QHXHT4hd2lmKVei5 text.actor>tspan{fill:#000;stroke:none}#mermaid-svg-QHXHT4hd2lmKVei5 .actor-line{stroke:grey}#mermaid-svg-QHXHT4hd2lmKVei5 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .messageLine1{stroke-width:1.5;stroke-dasharray:2, 2;stroke:#333}#mermaid-svg-QHXHT4hd2lmKVei5 #arrowhead path{fill:#333;stroke:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .sequenceNumber{fill:#fff}#mermaid-svg-QHXHT4hd2lmKVei5 #sequencenumber{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 #crosshead path{fill:#333;stroke:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .messageText{fill:#333;stroke:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .labelBox{stroke:#ccf;fill:#ECECFF}#mermaid-svg-QHXHT4hd2lmKVei5 .labelText,#mermaid-svg-QHXHT4hd2lmKVei5 .labelText>tspan{fill:#000;stroke:none}#mermaid-svg-QHXHT4hd2lmKVei5 .loopText,#mermaid-svg-QHXHT4hd2lmKVei5 .loopText>tspan{fill:#000;stroke:none}#mermaid-svg-QHXHT4hd2lmKVei5 .loopLine{stroke-width:2px;stroke-dasharray:2, 2;stroke:#ccf;fill:#ccf}#mermaid-svg-QHXHT4hd2lmKVei5 .note{stroke:#aa3;fill:#fff5ad}#mermaid-svg-QHXHT4hd2lmKVei5 .noteText,#mermaid-svg-QHXHT4hd2lmKVei5 .noteText>tspan{fill:#000;stroke:none}#mermaid-svg-QHXHT4hd2lmKVei5 .activation0{fill:#f4f4f4;stroke:#666}#mermaid-svg-QHXHT4hd2lmKVei5 .activation1{fill:#f4f4f4;stroke:#666}#mermaid-svg-QHXHT4hd2lmKVei5 .activation2{fill:#f4f4f4;stroke:#666}#mermaid-svg-QHXHT4hd2lmKVei5 .mermaid-main-font{font-family:"trebuchet ms", verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .section{stroke:none;opacity:0.2}#mermaid-svg-QHXHT4hd2lmKVei5 .section0{fill:rgba(102,102,255,0.49)}#mermaid-svg-QHXHT4hd2lmKVei5 .section2{fill:#fff400}#mermaid-svg-QHXHT4hd2lmKVei5 .section1,#mermaid-svg-QHXHT4hd2lmKVei5 .section3{fill:#fff;opacity:0.2}#mermaid-svg-QHXHT4hd2lmKVei5 .sectionTitle0{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .sectionTitle1{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .sectionTitle2{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .sectionTitle3{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .sectionTitle{text-anchor:start;font-size:11px;text-height:14px;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .grid .tick{stroke:#d3d3d3;opacity:0.8;shape-rendering:crispEdges}#mermaid-svg-QHXHT4hd2lmKVei5 .grid .tick text{font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .grid path{stroke-width:0}#mermaid-svg-QHXHT4hd2lmKVei5 .today{fill:none;stroke:red;stroke-width:2px}#mermaid-svg-QHXHT4hd2lmKVei5 .task{stroke-width:2}#mermaid-svg-QHXHT4hd2lmKVei5 .taskText{text-anchor:middle;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .taskText:not([font-size]){font-size:11px}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutsideRight{fill:#000;text-anchor:start;font-size:11px;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutsideLeft{fill:#000;text-anchor:end;font-size:11px}#mermaid-svg-QHXHT4hd2lmKVei5 .task.clickable{cursor:pointer}#mermaid-svg-QHXHT4hd2lmKVei5 .taskText.clickable{cursor:pointer;fill:#003163 !important;font-weight:bold}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutsideLeft.clickable{cursor:pointer;fill:#003163 !important;font-weight:bold}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutsideRight.clickable{cursor:pointer;fill:#003163 !important;font-weight:bold}#mermaid-svg-QHXHT4hd2lmKVei5 .taskText0,#mermaid-svg-QHXHT4hd2lmKVei5 .taskText1,#mermaid-svg-QHXHT4hd2lmKVei5 .taskText2,#mermaid-svg-QHXHT4hd2lmKVei5 .taskText3{fill:#fff}#mermaid-svg-QHXHT4hd2lmKVei5 .task0,#mermaid-svg-QHXHT4hd2lmKVei5 .task1,#mermaid-svg-QHXHT4hd2lmKVei5 .task2,#mermaid-svg-QHXHT4hd2lmKVei5 .task3{fill:#8a90dd;stroke:#534fbc}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutside0,#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutside2{fill:#000}#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutside1,#mermaid-svg-QHXHT4hd2lmKVei5 .taskTextOutside3{fill:#000}#mermaid-svg-QHXHT4hd2lmKVei5 .active0,#mermaid-svg-QHXHT4hd2lmKVei5 .active1,#mermaid-svg-QHXHT4hd2lmKVei5 .active2,#mermaid-svg-QHXHT4hd2lmKVei5 .active3{fill:#bfc7ff;stroke:#534fbc}#mermaid-svg-QHXHT4hd2lmKVei5 .activeText0,#mermaid-svg-QHXHT4hd2lmKVei5 .activeText1,#mermaid-svg-QHXHT4hd2lmKVei5 .activeText2,#mermaid-svg-QHXHT4hd2lmKVei5 .activeText3{fill:#000 !important}#mermaid-svg-QHXHT4hd2lmKVei5 .done0,#mermaid-svg-QHXHT4hd2lmKVei5 .done1,#mermaid-svg-QHXHT4hd2lmKVei5 .done2,#mermaid-svg-QHXHT4hd2lmKVei5 .done3{stroke:grey;fill:#d3d3d3;stroke-width:2}#mermaid-svg-QHXHT4hd2lmKVei5 .doneText0,#mermaid-svg-QHXHT4hd2lmKVei5 .doneText1,#mermaid-svg-QHXHT4hd2lmKVei5 .doneText2,#mermaid-svg-QHXHT4hd2lmKVei5 .doneText3{fill:#000 !important}#mermaid-svg-QHXHT4hd2lmKVei5 .crit0,#mermaid-svg-QHXHT4hd2lmKVei5 .crit1,#mermaid-svg-QHXHT4hd2lmKVei5 .crit2,#mermaid-svg-QHXHT4hd2lmKVei5 .crit3{stroke:#f88;fill:red;stroke-width:2}#mermaid-svg-QHXHT4hd2lmKVei5 .activeCrit0,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCrit1,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCrit2,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCrit3{stroke:#f88;fill:#bfc7ff;stroke-width:2}#mermaid-svg-QHXHT4hd2lmKVei5 .doneCrit0,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCrit1,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCrit2,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCrit3{stroke:#f88;fill:#d3d3d3;stroke-width:2;cursor:pointer;shape-rendering:crispEdges}#mermaid-svg-QHXHT4hd2lmKVei5 .milestone{transform:rotate(45deg) scale(0.8, 0.8)}#mermaid-svg-QHXHT4hd2lmKVei5 .milestoneText{font-style:italic}#mermaid-svg-QHXHT4hd2lmKVei5 .doneCritText0,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCritText1,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCritText2,#mermaid-svg-QHXHT4hd2lmKVei5 .doneCritText3{fill:#000 !important}#mermaid-svg-QHXHT4hd2lmKVei5 .activeCritText0,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCritText1,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCritText2,#mermaid-svg-QHXHT4hd2lmKVei5 .activeCritText3{fill:#000 !important}#mermaid-svg-QHXHT4hd2lmKVei5 .titleText{text-anchor:middle;font-size:18px;fill:#000;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 g.classGroup text{fill:#9370db;stroke:none;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family);font-size:10px}#mermaid-svg-QHXHT4hd2lmKVei5 g.classGroup text .title{font-weight:bolder}#mermaid-svg-QHXHT4hd2lmKVei5 g.clickable{cursor:pointer}#mermaid-svg-QHXHT4hd2lmKVei5 g.classGroup rect{fill:#ECECFF;stroke:#9370db}#mermaid-svg-QHXHT4hd2lmKVei5 g.classGroup line{stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5}#mermaid-svg-QHXHT4hd2lmKVei5 .classLabel .label{fill:#9370db;font-size:10px}#mermaid-svg-QHXHT4hd2lmKVei5 .relation{stroke:#9370db;stroke-width:1;fill:none}#mermaid-svg-QHXHT4hd2lmKVei5 .dashed-line{stroke-dasharray:3}#mermaid-svg-QHXHT4hd2lmKVei5 #compositionStart{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #compositionEnd{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #aggregationStart{fill:#ECECFF;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #aggregationEnd{fill:#ECECFF;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #dependencyStart{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #dependencyEnd{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #extensionStart{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 #extensionEnd{fill:#9370db;stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 .commit-id,#mermaid-svg-QHXHT4hd2lmKVei5 .commit-msg,#mermaid-svg-QHXHT4hd2lmKVei5 .branch-label{fill:lightgrey;color:lightgrey;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .pieTitleText{text-anchor:middle;font-size:25px;fill:#000;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .slice{font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 g.stateGroup text{fill:#9370db;stroke:none;font-size:10px;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 g.stateGroup text{fill:#9370db;fill:#333;stroke:none;font-size:10px}#mermaid-svg-QHXHT4hd2lmKVei5 g.statediagram-cluster .cluster-label text{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 g.stateGroup .state-title{font-weight:bolder;fill:#000}#mermaid-svg-QHXHT4hd2lmKVei5 g.stateGroup rect{fill:#ECECFF;stroke:#9370db}#mermaid-svg-QHXHT4hd2lmKVei5 g.stateGroup line{stroke:#9370db;stroke-width:1}#mermaid-svg-QHXHT4hd2lmKVei5 .transition{stroke:#9370db;stroke-width:1;fill:none}#mermaid-svg-QHXHT4hd2lmKVei5 .stateGroup .composit{fill:white;border-bottom:1px}#mermaid-svg-QHXHT4hd2lmKVei5 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px}#mermaid-svg-QHXHT4hd2lmKVei5 .state-note{stroke:#aa3;fill:#fff5ad}#mermaid-svg-QHXHT4hd2lmKVei5 .state-note text{fill:black;stroke:none;font-size:10px}#mermaid-svg-QHXHT4hd2lmKVei5 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.7}#mermaid-svg-QHXHT4hd2lmKVei5 .edgeLabel text{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .stateLabel text{fill:#000;font-size:10px;font-weight:bold;font-family:'trebuchet ms', verdana, arial;font-family:var(--mermaid-font-family)}#mermaid-svg-QHXHT4hd2lmKVei5 .node circle.state-start{fill:black;stroke:black}#mermaid-svg-QHXHT4hd2lmKVei5 .node circle.state-end{fill:black;stroke:white;stroke-width:1.5}#mermaid-svg-QHXHT4hd2lmKVei5 #statediagram-barbEnd{fill:#9370db}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370db;stroke-width:1px}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-cluster rect.outer{rx:5px;ry:5px}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-state .divider{stroke:#9370db}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-state .title-state{rx:5px;ry:5px}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-cluster.statediagram-cluster .inner{fill:white}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#e0e0e0}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-cluster .inner{rx:0;ry:0}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-state rect.basic{rx:5px;ry:5px}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#efefef}#mermaid-svg-QHXHT4hd2lmKVei5 .note-edge{stroke-dasharray:5}#mermaid-svg-QHXHT4hd2lmKVei5 .statediagram-note rect{fill:#fff5ad;stroke:#aa3;stroke-width:1px;rx:0;ry:0}:root{--mermaid-font-family: '"trebuchet ms", verdana, arial';--mermaid-font-family: "Comic Sans MS", "Comic Sans", cursive}#mermaid-svg-QHXHT4hd2lmKVei5 .error-icon{fill:#522}#mermaid-svg-QHXHT4hd2lmKVei5 .error-text{fill:#522;stroke:#522}#mermaid-svg-QHXHT4hd2lmKVei5 .edge-thickness-normal{stroke-width:2px}#mermaid-svg-QHXHT4hd2lmKVei5 .edge-thickness-thick{stroke-width:3.5px}#mermaid-svg-QHXHT4hd2lmKVei5 .edge-pattern-solid{stroke-dasharray:0}#mermaid-svg-QHXHT4hd2lmKVei5 .edge-pattern-dashed{stroke-dasharray:3}#mermaid-svg-QHXHT4hd2lmKVei5 .edge-pattern-dotted{stroke-dasharray:2}#mermaid-svg-QHXHT4hd2lmKVei5 .marker{fill:#333}#mermaid-svg-QHXHT4hd2lmKVei5 .marker.cross{stroke:#333}:root { --mermaid-font-family: "trebuchet ms", verdana, arial;}#mermaid-svg-QHXHT4hd2lmKVei5 {color: rgba(0, 0, 0, 0.75);font: ;}

回调本系统
Yes
No
用户输入用户名密码进行绑定
第三方登陆
触发第三方平台授权
判断第三方平台数据存在本系统
触发本系统登陆流程
触发用户绑定流程

第三方登陆分两种情况:
1,(已绑定用户)第三方平台授权,直接登陆
2,(未绑定用户)第三方平台授权,用户绑定后,登陆

思路

关于第三方登陆实现:

直接参考 JustAuth 提供的说明文档,简单明了。JustAuth

关于对接Spring Security的思路:

在前几篇介绍中,我们使用了Spring Security 进行系统的认证和授权,提供了基于用户名和密码表单登陆的方式,并且使用了Spring Session 和 Redisson 框架来管理会话信息。那么第三方登录将作为本系统用户登陆方式的一种扩展,我们期望不要由于第三方登录的出现,修改之前的认证授权流程和会话管理。所以自定义一套认证流程是一个不错的选择。

有一个大家比较关心的问题:

第三方用户信息怎么与现有用户系统整合?

最简单的就是设计一张第三方用户信息表,将第三方用户信息绑定到现有用户信息上。在登陆过程后,将第三方用户信息替换为现有用户信息实现登陆。

-- 第三方系统用户表
CREATE TABLE `other_sys_user` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',`scope` varchar(64) DEFAULT NULL COMMENT '第三方系统',`uuid` varchar(64) DEFAULT NULL COMMENT '第三方系统唯一账户',`user_id` bigint(20) DEFAULT NULL COMMENT '关联系统用户表的id',`username` varchar(64) DEFAULT NULL COMMENT '登陆用户名',`create_time` datetime DEFAULT NULL COMMENT '绑定时间(创建时间)',`update_time` datetime DEFAULT NULL COMMENT '更新时间',PRIMARY KEY (`id`),KEY `idx_user_id` (`user_id`) USING BTREE COMMENT '用户id索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

一个现有用户可能会匹配多个第三方系统的用户信息(1:n)。

JustAuth也给出一种登陆方案: JustAuth与用户系统整合流程图 可以参考学习

怎么设计一个通用的第三方登陆实现呢?

无论是要对接哪个平台,只需简单的配置,就可以实现第三方系统登陆。

每个平台厂商对OAuth2的实现不同,api接口等会不一样。JustAuth框架提供了大多数平台的对接接口,在一定程度上屏蔽了登陆的平台差异。对于我们实现的难点,在于怎么知道用户选择了那个第三方平台授权登陆。想知道这个很easy,用户使用哪个平台登陆,就将对应平台的syscode(比如 qq,github等等),当做参数传入。

自定义登陆流程

根据上述的登陆流程,会细分出两个认证流程:

  1. 已经绑定现有用户的情况下(说明之前已经使用第三方登陆,登陆本系统)。

    点击第三方登陆,直接登陆本系统

  2. 没有绑定现有用户的情况下(说明第一次使用第三方登陆,登陆本系统)。

    点击第三方登陆,判断没有绑定用户,跳转绑定用户界面,绑定后登陆

Created with Raphaël 2.2.0开始OAuth2LoginController/api/login/{sys} 重定向第三方登陆页面用户选择是否授权?第三方登陆发起回调AbstractAuthenticationProcessingFilter -- doFilter()OtherSysOauth2LoginFilter -- attemptAuthentication()用户是否已经绑定?OtherSysOauth2LoginAuthenticationTokenProviderManagerOtherSysOauth2LoginProvider -- authenticate()OtherSysOauth2LoginUserDetailsServiceImpl -- loadUserByUsername()执行认证成功事件或者失败事件结束跳转到绑定登陆页面用户输入用户名和密码绑定登陆AbstractAuthenticationProcessingFilter -- doFilter()UserBindFilter -- attemptAuthentication()BindAuthenticationTokenProviderManagerBindLoginProvider -- authenticate()UserDetailsService -- loadUserByUsername()执行认证成功事件或者失败事件结束yesyesno

平行四边形中就是需要创建的类。

其实OtherSysOauth2LoginAuthenticationToken 以及后面的认证(Provider)用户详情服务可以不用重新创建,直接使用UsernamePasswordAuthenticationToken那一套就可以了。但是为了演示整个认证流程,我还是重写了Token以及认证(Provider)和用户详情服务,万一用上了呢。

代码实现

项目环境:spring boot 2.2.7 Jpa java8 mysql5.7 JustAuth1.15.8

代码参考:https://github.com/gengzi/gengzi_spring_security/tree/master/gengzi_spring_security

核心依赖

       <!--   JustAuth 对接第三方登陆框架     --><dependency><groupId>me.zhyd.oauth</groupId><artifactId>JustAuth</artifactId><version>1.15.8</version></dependency>

JustAuth 官方文档参考: https://justauth.wiki/#/

数据库文件:sys_manager.sql

创建第三方授权应用

不介绍了,直接参考把,写的非常清楚。

https://justauth.wiki/# 集成第三方部分。

主要是配置一下回调地址(redirectUri ),例如:http://localhost:8081/api/v1/oauth/callback/{sysCode}

注意最后一个参数sysCode ,就是对应平台的code,这也是实现通用第三方登陆的关键。

比如Github网站OAuth2应用,配置:http://localhost:8081/api/v1/oauth/callback/github

比如Gitee网站OAuth2应用,配置:http://localhost:8081/api/v1/oauth/callback/gitee

调用第三方平台部分

页面:

login.html

代码参考:login.html

<h2>其他方式登录</h2>
<button class="but" onClick="other_login('github');" type="button" style="margin-top:10px">github授权登陆</button>
<button class="but" onClick="other_login('gitee');" type="button" style="margin-top:10px" >gitee授权登陆</button><script>// 第三方登陆function other_login(sys) {window.location.href = "/api/v1/oauth/login?oauthSysCode="+sys;}
</script>

controller 层

代码参考:Oauth2LoginController

@Api(value = "第三方登陆", tags = {"第三方登陆"})
@Controller
@RequestMapping("/api/v1/oauth")
public class Oauth2LoginController {@Autowiredprivate AuthRequestService authRequestService;@ApiOperation(value = "登陆接口", notes = "登陆接口")@ApiImplicitParams({@ApiImplicitParam(name = "oauthSysCode", value = "第三方系统syscode", required = true)})@GetMapping("/login")public String oauthLogin(@RequestParam("oauthSysCode") String oauthSysCode) {// 根据code ,获取对应第三方系统的 AuthRequestAuthRequest authRequest = authRequestService.getAuthRequest(oauthSysCode);// 重定向认证地址return "redirect:" + authRequest.authorize(AuthStateUtils.createState());}}

service 层

这里就是通用第三方登陆,根据syscode,构造对应平台请求。

代码参考:AuthRequestServiceImpl.java

@Service
public class AuthRequestServiceImpl implements AuthRequestService {// 读取第三方登陆配置的实体类@Autowiredprivate AuthRequestConfigEntity authRequestConfigEntity;@SneakyThrows@Overridepublic AuthRequest getAuthRequest(String sys) {AuthRequest authRequest = null;List<AuthRequestConfigEntity.AuthRequestInfo> othersys = authRequestConfigEntity.getOthersys();// 如果配置多个相同名称的 第三方系统,仅获取第一个配置信息Optional<AuthRequestConfigEntity.AuthRequestInfo> info = othersys.stream().filter(authRequestInfo -> authRequestInfo.getName().equalsIgnoreCase(sys)).findFirst();AuthRequestConfigEntity.AuthRequestInfo authRequestInfo = info.orElseThrow(() -> new RrException("不存在" + sys + "该系统的第三方登陆配置,请在yml文件中加入该系统的配置", RspCodeEnum.ERROR.getCode()));// 构造 AuthConfigAuthConfig config = AuthConfig.builder().clientId(authRequestInfo.getClientId()).clientSecret(authRequestInfo.getClientSecret()).redirectUri(authRequestInfo.getRedirectUri()).build();// 反射,使用有参的构造方法,创建对象Class aClass = Oauth2LoginConstant.sysMappingClazz.get(sys);Constructor constructor = aClass.getConstructor(AuthConfig.class);Object obj = constructor.newInstance(config);if (obj instanceof AuthRequest) {authRequest = (AuthRequest) obj;}return authRequest;}
}

再引入一个常量类, syscode 匹配 对应平台的请求类,就是通过Map 来实现的。还有一种更加简单彻底的做法,全部有配置文件控制,可以发挥下思路,也是同样的道理。

/**
* <h1>第三方登陆的全局静态变量</h1>
*
* @author gengzi
* @date 2020年11月28日18:15:35
*/
public class Oauth2LoginConstant {// TODO 懒得写了,有需要增加的第三方系统,增加属性配置就可以了public static final String SYS_GITHUB = "github";public static final String SYS_GITEE = "gitee";// 映射的classpublic static HashMap<String, Class> sysMappingClazz = new HashMap<>();// 允许第三方登陆的系统public static final String SYS_SOURCE[] = {Oauth2LoginConstant.SYS_GITHUB, Oauth2LoginConstant.SYS_GITEE};static {sysMappingClazz.putIfAbsent(SYS_GITHUB, AuthGithubRequest.class);sysMappingClazz.putIfAbsent(SYS_GITEE, AuthGiteeRequest.class);}}

核心配置

application.yml

如果你还需要增加其他第三方登陆,继续追加配置即可。

oauth2:othersys:- name: githubclient_id: # 客户端身份标识符(应用id),一般在申请完Oauth应用后,由第三方平台颁发,唯一client_secret: #配置 客户端密钥,一般在申请完Oauth应用后,由第三方平台颁发redirectUri: http://localhost:8081/api/v1/oauth/callback/github # 回调地址- name: giteeclient_id: # 客户端身份标识符(应用id),一般在申请完Oauth应用后,由第三方平台颁发,唯一client_secret: #配置 客户端密钥,一般在申请完Oauth应用后,由第三方平台颁发redirectUri: http://localhost:8081/api/v1/oauth/callback/gitee # 回调地址

可以参数: 配置转对象

配置对象:将配置转换为对象实体,方便我们使用。

代码参考:AuthRequestConfigEntity.java

@Configuration
@ConfigurationProperties(prefix = "oauth2")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthRequestConfigEntity<T> implements Serializable {// 所有的第三方登陆的客户端信息private List<AuthRequestInfo> othersys = new ArrayList<>();/*** 具体信息*/@Data@AllArgsConstructor@NoArgsConstructorpublic static class AuthRequestInfo {// 第三方系统名称private String name;// 客户端idprivate String clientId;// 客户端密钥private String clientSecret;// 回调地址private String redirectUri;}}

上述步骤,仅仅到了第三方授权页面。(图示第二个)

通用第三方登陆配置

对于如果需要增加其他第三方平台登陆支持:

只需要增加 application.yml 中的oauth2的配置和常量类 Oauth2LoginConstant 中的,系统code 与认证请求类的匹配。

本系统的授权认证部分

当用户点击“同意授权”,第三方平台就开始回调之前配置的本系统的回调地址,也就要进入认证登陆流程了。

认证流程一:已经绑定现有用户流程

OtherSysOauth2LoginFilter

代码参考: OtherSysOauth2LoginFilter.java

只粘核心代码,这里上述中的 AuthRequestServiceImpl 用来返回对应平台的,认证请求类。

   public class OtherSysOauth2LoginFilter extends AbstractAuthenticationProcessingFilter {// 拦截路径,触发该filter 的执行private static final String REDIRECTURI = "/api/v1/oauth/callback/**";/*** 初始化拦截路径*/public OtherSysOauth2LoginFilter() {super(new AntPathRequestMatcher(REDIRECTURI));}// 预验证方法 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {OtherSysOauth2LoginAuthenticationToken token = null;// 解析地址,获得第三方平台syscodeString path = request.getServletPath();String[] sysArr = path.split("/api/v1/oauth/callback/");String sys = sysArr[sysArr.length - 1];// 判断当前系统是否支持该平台登陆boolean contains = Arrays.asList(Oauth2LoginConstant.SYS_SOURCE).contains(sys);if (!contains) {throw new AuthenticationServiceException("暂不支持此系统登录(This system login is not currently supported)");}// 构造AuthRequestAuthRequest authRequest = this.getAuthRequest(sys);if (authRequest == null) {throw new AuthenticationServiceException("暂不支持此系统登录(This system login is not currently supported)");}// 去第三方平台获取用户信息AuthResponse<AuthUser> authResponse = authRequest.login(this.getCallback(request));if (authResponse.ok()) {// 获取第三方登陆信息成功AuthUser data = authResponse.getData();// 用户id 一般是唯一的。建议通过uuid + source的方式唯一确定一个用户,这样可以解决用户身份归属的问题。String id = data.getUuid();// 根据当前系统和uuid,查询数据库,获取绑定本系统的用户信息OtherSysUser otherSysUser = this.getOtherUsersService().getOtherSysUserByUUIDAndScope(sys, id);if (otherSysUser == null) {// 未绑定过,第一次使用这个第三方平台登陆String uuid = UUID.randomUUID().toString();// 缓存一下第三方平台的用户信息,方便后续使用redisUtil.set(Oauth2LoginRedisKeysConstant.OTHER_SYS_USER_INFO + uuid, data, 300);// 跳转到绑定页面response.sendRedirect("/oauthlogin.html?token=" + uuid + "&scope=" + sys);return null;} else {// 存在绑定用户信息,说明不是第一次使用这个第三方平台登陆,执行认证流程ReturnData returnData = this.getUsersService().loadUserByUsername(otherSysUser.getUsername());// 这里直接使用了 user 的全部信息,设置到了 principal 主体中,方便使用token = new OtherSysOauth2LoginAuthenticationToken(returnData.getInfo());}}// 设置额外参数this.setDetails(request, token);// 去认证return this.getAuthenticationManager().authenticate(token);} }

OtherSysOauth2LoginAuthenticationToken

这里要注意还没有进入认证时, setAuthenticated(false); 要设置为false。

认证完毕后,如果认证成功,再设置为 true。super.setAuthenticated(true); // must use super, as we override

代码参考:OtherSysOauth2LoginAuthenticationToken.java

public class OtherSysOauth2LoginAuthenticationToken extends AbstractAuthenticationToken {private final Object principal;private Object credentials;public OtherSysOauth2LoginAuthenticationToken(Object principal, Object credentials) {super(null);this.principal = principal;this.credentials = credentials;setAuthenticated(false);}public OtherSysOauth2LoginAuthenticationToken(Object authUser) {super(null);this.principal = authUser;setAuthenticated(false);}/*** 使用提供的权限数组创建令牌。** @param authorities 权限集合* @param principal   用户名* @param credentials 密码*/public OtherSysOauth2LoginAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {super(authorities);this.principal = principal;this.credentials = credentials;super.setAuthenticated(true); // must use super, as we override}// 密码@Overridepublic Object getCredentials() {return this.credentials;}// 用户名@Overridepublic Object getPrincipal() {return this.principal;}}

OtherSysOauth2LoginProvider

代码参考:OtherSysOauth2LoginProvider.java


/**
* <h1>第三方登陆的提供者</h1>
* <p>
* 参考: {@link AbstractUserDetailsAuthenticationProvider} 实现
* 一些方法直接默认使用。
* 可以将AbstractUserDetailsAuthenticationProvider 整个类拷贝后,仅修改一些参数和方法
* <p>
* 作用:
* 判断token类型是否一致,不是OtherSysOauth2LoginAuthenticationToken ,不执行认证流程。这样将表单登陆,绑定用户登陆,还是第三方直接登录(已经绑定)
* 认证过程区分开。
* <p>
* 校验基本的信息后,就赋予认证成功的标识。
* <p>
* <p>
* 注意: 该Provider 并没有提供对密码的校验。因为第三方登陆,不会输入本系统用户名和密码。只要数据库表能找到该用户,默认该用户已经认证成功了。
* 如果需要真正执行密码校验的操作,请参阅{@link DaoAuthenticationProvider} 的 additionalAuthenticationChecks 方法实现。
*
* @author gengzi
* @date 2020年12月5日12:41:07
*/
@Slf4j
public class OtherSysOauth2LoginProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();private UserCache userCache = new NullUserCache();private boolean forcePrincipalAsString = false;protected boolean hideUserNotFoundExceptions = true;private UserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks();private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks();private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();// 用户详情服务,用于查询用户详情信息private OtherSysOauth2LoginUserDetailsServiceImpl userDetailsService;@Overridepublic void afterPropertiesSet() throws Exception {}@Overridepublic void setMessageSource(MessageSource messageSource) {this.messages = new MessageSourceAccessor(messageSource);}/*** 认证方法** @param authentication {@link OtherSysOauth2LoginAuthenticationToken} token* @return* @throws AuthenticationException*/@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {// 判断认证方式是否属于 OtherSysOauth2LoginAuthenticationTokenAssert.isInstanceOf(OtherSysOauth2LoginAuthenticationToken.class, authentication, "仅支持OtherSysOauth2LoginAuthenticationToken类型的认证");boolean cacheWasUsed = true;// 根据信息获取 UserDetails 的信息UserDetail principal = (UserDetail) authentication.getPrincipal();String id = principal.getUsername();UserDetails user = this.userCache.getUserFromCache(String.valueOf(id));if (user == null) {cacheWasUsed = false;user = retrieveUser(String.valueOf(id),(OtherSysOauth2LoginAuthenticationToken) authentication);}try {preAuthenticationChecks.check(user);} catch (AuthenticationException exception) {throw exception;}postAuthenticationChecks.check(user);if (!cacheWasUsed) {this.userCache.putUserInCache(user);}Object principalToReturn = user;if (forcePrincipalAsString) {principalToReturn = user.getUsername();}return createSuccessAuthentication(principalToReturn, authentication, user);}/*** 创建成功认证** @param principal      主体* @param authentication token* @param user           用户详情* @return*/protected Authentication createSuccessAuthentication(Object principal,Authentication authentication, UserDetails user) {// Ensure we return the original credentials the user supplied,// so subsequent attempts are successful even with encoded passwords.// Also ensure we return the original getDetails(), so that future// authentication events after cache expiry contain the detailsOtherSysOauth2LoginAuthenticationToken result = new OtherSysOauth2LoginAuthenticationToken(authoritiesMapper.mapAuthorities(user.getAuthorities()),principal, authentication.getCredentials());result.setDetails(authentication.getDetails());return result;}/*** 检索用户详情* <p>* 从数据库中查询** @param username       用户名* @param authentication* @return* @throws AuthenticationException*/protected UserDetails retrieveUser(String username, OtherSysOauth2LoginAuthenticationToken authentication) throws AuthenticationException {try {UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);if (loadedUser == null) {throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");}return loadedUser;} catch (InternalAuthenticationServiceException ex) {throw ex;} catch (Exception ex) {throw new InternalAuthenticationServiceException(ex.getMessage(), ex);}}public void setUserDetailsService(OtherSysOauth2LoginUserDetailsServiceImpl userDetailsService) {this.userDetailsService = userDetailsService;}protected OtherSysOauth2LoginUserDetailsServiceImpl getUserDetailsService() {return userDetailsService;}@Overridepublic boolean supports(Class<?> authentication) {return (OtherSysOauth2LoginAuthenticationToken.class.isAssignableFrom(authentication));}/*** 默认的预身份验证检查*/private class DefaultPreAuthenticationChecks implements UserDetailsChecker {@Overridepublic void check(UserDetails user) {if (!user.isAccountNonLocked()) {log.debug("User account is locked");throw new LockedException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked","User account is locked"));}if (!user.isEnabled()) {log.debug("User account is disabled");throw new DisabledException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled","User is disabled"));}if (!user.isAccountNonExpired()) {log.debug("User account is expired");throw new AccountExpiredException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired","User account has expired"));}}}private class DefaultPostAuthenticationChecks implements UserDetailsChecker {@Overridepublic void check(UserDetails user) {if (!user.isCredentialsNonExpired()) {log.debug("User account credentials have expired");throw new CredentialsExpiredException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired","User credentials have expired"));}}}
}

OtherSysOauth2LoginUserDetailsServiceImpl

代码参考:OtherSysOauth2LoginUserDetailsServiceImpl.java

其实跟之前实现的 UserDetailsService 没啥差别。

/**
* <h1>用户详细服务impl</h1>
* <p>
* 用于返回根据用户名返回用户详细信息,以便于供 security 使用
*
* @author gengzi
* @date 2020年11月3日15:24:43
*/
@Service("otherSysOauth2LoginUserDetailsServiceImpl")
public class OtherSysOauth2LoginUserDetailsServiceImpl {@Autowiredprivate UsersService usersService;public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {ReturnData result = usersService.loadUserByUsername(username);if (RspCodeEnum.NOTOKEN.getCode() == result.getStatus()) {throw new RrException(RspCodeEnum.ACCOUNT_NOT_EXIST.getDesc());}UserDetail userDetail = (UserDetail) result.getInfo();if (userDetail == null) {throw new RrException(RspCodeEnum.ACCOUNT_NOT_EXIST.getDesc());}//账号不可用if (userDetail.getStatus() == UserStatusEnum.DISABLE.getValue()) {userDetail.setEnabled(false);}return userDetail;}
}

认证成功事件或者失败事件

当认证完成后,就会执行默认或者自定义的认证成功事件或者失败事件。

可以根据业务需求修改,这里都返回的是 json 的数据

失败:

/**
* <h1>用户认证失败处理器</h1>
* 响应失败的json 信息
*
*
* @author gengzi
* @date 2020年11月24日13:23:40
*/
@Component
public class UserAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {@SneakyThrows@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {response.setContentType("application/json;charset=UTF-8");ReturnData ret = ReturnData.newInstance();ret.setFailure(e.getMessage());response.getWriter().write(JSON.toJSONString(ret));}
}

成功:

/**
* <h1>用户认证成功处理器</h1>
* 从session中获取,响应脱敏的用户信息
*
* @author gengzi
* @date 2020年11月24日13:23:40
*/
@Component
@Slf4j
public class UserAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {@SneakyThrows@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {response.setContentType("application/json;charset=UTF-8");ReturnData ret = ReturnData.newInstance();ret.setSuccess();ret.setInfo(authentication);response.getWriter().write(JSON.toJSONString(ret));}
}

认证流程二:没有绑定现有用户流程

依然要执行 OtherSysOauth2LoginFilter ,只不过判断用户未绑定后,跳转到了绑定页面

绑定用户页面

跳转绑定用户页面

绑定页面地址:"/oauthlogin.html?token=" + uuid + “&scope=” + sys

例如:http://localhost:8081/oauthlogin.html?token=3db4aa22-0aea-4604-9c6e-56fe7ffc86ce&scope=github

代码参考:oauthlogin.html

    <h1>绑定系统账号</h1><form method="post" action="/otherlogin"><input type="hidden" name="scope" id="scope" value="github"><input type="hidden" name="token" id="token" value=""><input type="text" required="required" placeholder="用户名" name="username"></input><input type="password" required="required" placeholder="密码" name="password"></input><button class="but" type="submit">绑定并登陆账户</button></form><script>$(function () {// 获取路径中的参数function getUrlParms(name) {var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");var r = window.location.search.substr(1).match(reg);if (r != null)return unescape(r[2]);return null;}var token = getUrlParms("token");var scope = getUrlParms("scope");$("#token").val(token);$("#scope").val(scope);});</script>

UserBindFilter

代码参考:UserBindFilter.java


/**
* <h1>用户绑定过滤器</h1>
* <p>
* 触发条件: 当第三方登陆用户与本系统用户绑定时,执行该过滤器
* <p>* 匹配路径:/otherlogin  Post 请求
* <p>
* 根据绑定用户入参,将第三方用户信息入库,与本系统用户关联
* <p>
* 参见:UsernamePasswordAuthenticationFilter 实现
* <p>*     使用示例:
**         UserBindFilter userBindFilter = new UserBindFilter();*         userBindFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));*         userBindFilter.setRedisUtil(redisUtil);*         userBindFilter.setOtherSysUserDao(otherSysUserDao);*         userBindFilter.setSysUsersDao(sysUsersDao);*         // 再加入到 spring security 的过滤器链中*         httpSecurity.addFilterBefore(userBindFilter, UsernamePasswordAuthenticationFilter.class);
*
* @author gengzi
* @date 2020年11月24日10:45:17
*/
public class UserBindFilter extendsAbstractAuthenticationProcessingFilter {// ~ Static fields/initializers// =====================================================================================public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";public static final String SPRING_SECURITY_FORM_TOKEN_KEY = "token";public static final String SPRING_SECURITY_FORM_SCOPE_KEY = "scope";private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;private String tokenParameter = SPRING_SECURITY_FORM_TOKEN_KEY;private String scopeParameter = SPRING_SECURITY_FORM_SCOPE_KEY;private boolean postOnly = true;private RedisUtil redisUtil;private OtherUsersService otherUsersService;private OtherSysUserDao otherSysUserDao;private SysUsersDao sysUsersDao;public SysUsersDao getSysUsersDao() {return sysUsersDao;}public void setSysUsersDao(SysUsersDao sysUsersDao) {this.sysUsersDao = sysUsersDao;}public OtherSysUserDao getOtherSysUserDao() {return otherSysUserDao;}public void setOtherSysUserDao(OtherSysUserDao otherSysUserDao) {this.otherSysUserDao = otherSysUserDao;}public OtherUsersService getOtherUsersService() {return otherUsersService;}public void setOtherUsersService(OtherUsersService otherUsersService) {this.otherUsersService = otherUsersService;}public RedisUtil getRedisUtil() {return redisUtil;}public void setRedisUtil(RedisUtil redisUtil) {this.redisUtil = redisUtil;}// ~ Constructors// ===================================================================================================public UserBindFilter() {super(new AntPathRequestMatcher("/otherlogin", "POST"));}// ~ Methods// ========================================================================================================@Overridepublic Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse response) throws AuthenticationException {// 校验请求方式if (postOnly && !request.getMethod().equals("POST")) {throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());}// 获取请求的入参String username = obtainUsername(request);String password = obtainPassword(request);String token = obtainToken(request);String sys = obtainScope(request);// 参数校验部分if (username == null) {username = "";}if (password == null) {password = "";}if (token == null) {throw new AuthenticationServiceException("token 参数缺失(The token parameter is missing)");}// 从redis 缓存中获取第三方用户信息AuthUser authUser = (AuthUser) this.getRedisUtil().get(Oauth2LoginRedisKeysConstant.OTHER_SYS_USER_INFO + token);if (authUser == null) {throw new AuthenticationServiceException("绑定超时,请重新登陆绑定(Binding timed out, please log in again to bind)");}SysUsers sysUser = this.getSysUsersDao().findByUsername(username);if (sysUser == null) {throw new AuthenticationServiceException("输入用户名不存在(Enter username does not exist)");}// 将用户信息构造为 OtherSysUser 对象String uuid = authUser.getUuid();OtherSysUser otherSysUser = new OtherSysUser();otherSysUser.setScope(sys);otherSysUser.setUuid(uuid);otherSysUser.setCreateTime(new Date());otherSysUser.setUserId(sysUser.getId());otherSysUser.setUsername(sysUser.getUsername());username = username.trim();// 构造tokenBindAuthenticationToken bindAuthenticationToken = new BindAuthenticationToken(username, password,otherSysUser);// 设置额外数据setDetails(request, bindAuthenticationToken);// 移除缓存的用户数据,以防用户下次继续使用this.getRedisUtil().del(Oauth2LoginRedisKeysConstant.OTHER_SYS_USER_INFO + token);// 执行认证return this.getAuthenticationManager().authenticate(bindAuthenticationToken);}// 获取密码@Nullableprotected String obtainPassword(HttpServletRequest request) {return request.getParameter(passwordParameter);}// 获取用户名@Nullableprotected String obtainUsername(HttpServletRequest request) {return request.getParameter(usernameParameter);}// 获取token@Nullableprotected String obtainToken(HttpServletRequest request) {return request.getParameter(tokenParameter);}// 获取scope@Nullableprotected String obtainScope(HttpServletRequest request) {return request.getParameter(scopeParameter);}protected void setDetails(HttpServletRequest request,BindAuthenticationToken authRequest) {authRequest.setDetails(authenticationDetailsSource.buildDetails(request));}public void setUsernameParameter(String usernameParameter) {Assert.hasText(usernameParameter, "Username parameter must not be empty or null");this.usernameParameter = usernameParameter;}public void setPasswordParameter(String passwordParameter) {Assert.hasText(passwordParameter, "Password parameter must not be empty or null");this.passwordParameter = passwordParameter;}public void setPostOnly(boolean postOnly) {this.postOnly = postOnly;}public final String getUsernameParameter() {return usernameParameter;}public final String getPasswordParameter() {return passwordParameter;}
}

BindAuthenticationToken

代码参考:BindAuthenticationToken.java

/**
* <h1>绑定登陆认证令牌</h1>
* <p>
* 参考: UsernamePasswordAuthenticationToken 实现
* <p>
* 将登录信息构造一个成认证令牌,传递数据
*
* @author gengzi
* @date 2020年12月5日10:49:24
*/
public class BindAuthenticationToken extends AbstractAuthenticationToken {private final Object principal;private Object credentials;// 绑定信息private Object bindInfo;public Object getBindInfo() {return bindInfo;}public void setBindInfo(Object bindInfo) {this.bindInfo = bindInfo;}public BindAuthenticationToken(Object principal, Object credentials) {super(null);this.principal = principal;this.credentials = credentials;setAuthenticated(false);}public BindAuthenticationToken(Object principal, Object credentials, Object bindInfo) {super(null);this.principal = principal;this.credentials = credentials;this.bindInfo = bindInfo;setAuthenticated(false);}public BindAuthenticationToken(Object authUser) {super(null);this.principal = authUser;setAuthenticated(false);}/*** 使用提供的权限数组创建令牌。** @param authorities 权限集合* @param principal   用户名* @param credentials 密码*/public BindAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {super(authorities);this.principal = principal;this.credentials = credentials;super.setAuthenticated(true); // must use super, as we override}// 密码@Overridepublic Object getCredentials() {return this.credentials;}// 用户名 或者 主体@Overridepublic Object getPrincipal() {return this.principal;}}

BindLoginProvider

代码参考BindLoginProvider.java

关注注释部分的代码即可。其他代码都是 AbstractUserDetailsAuthenticationProvider 的


/**
* <h1>绑定登陆的提供者</h1>
* <p>
* 参考:AbstractUserDetailsAuthenticationProvider 实现
* <p>
* 作用:
* 1,校验用户信息,用户名和密码是否正确
* 2,用户信息正确,再将第三方用户信息存入数据库
*
* @author gengzi
* @date 2020年12月5日10:55:59
*/
@Slf4j
public class BindLoginProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {protected final Log logger = LogFactory.getLog(getClass());// ~ Instance fields// ================================================================================================protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();private UserCache userCache = new NullUserCache();private boolean forcePrincipalAsString = false;protected boolean hideUserNotFoundExceptions = true;private UserDetailsChecker preAuthenticationChecks = new BindLoginProvider.DefaultPreAuthenticationChecks();private UserDetailsChecker postAuthenticationChecks = new BindLoginProvider.DefaultPostAuthenticationChecks();private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();// 用户详情服务private UserDetailsServiceImpl userDetailsService;// 保存第三方用户信息的daoprivate OtherSysUserDao otherSysUserDao;// 密码encoderprivate PasswordEncoder passwordEncoder;public PasswordEncoder getPasswordEncoder() {return passwordEncoder;}public void setPasswordEncoder(PasswordEncoder passwordEncoder) {this.passwordEncoder = passwordEncoder;}public OtherSysUserDao getOtherSysUserDao() {return otherSysUserDao;}public void setOtherSysUserDao(OtherSysUserDao otherSysUserDao) {this.otherSysUserDao = otherSysUserDao;}public UserDetailsServiceImpl getUserDetailsService() {return userDetailsService;}public void setUserDetailsService(UserDetailsServiceImpl userDetailsService) {this.userDetailsService = userDetailsService;}// ~ Methods// ========================================================================================================//    protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
//                                                           BindAuthenticationToken authentication)
//            throws AuthenticationException;@Overridepublic final void afterPropertiesSet() throws Exception {Assert.notNull(this.userCache, "A user cache must be set");Assert.notNull(this.messages, "A message source must be set");doAfterPropertiesSet();}@Overridepublic Authentication authenticate(Authentication authentication)throws AuthenticationException {Assert.isInstanceOf(BindAuthenticationToken.class, authentication,() -> messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports","Only BindAuthenticationToken is supported"));// Determine usernameString username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED": authentication.getName();boolean cacheWasUsed = true;UserDetails user = this.userCache.getUserFromCache(username);if (user == null) {cacheWasUsed = false;try {user = retrieveUser(username,(BindAuthenticationToken) authentication);} catch (UsernameNotFoundException notFound) {logger.debug("User '" + username + "' not found");if (hideUserNotFoundExceptions) {throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials","Bad credentials"));} else {throw notFound;}}Assert.notNull(user,"retrieveUser returned null - a violation of the interface contract");}try {preAuthenticationChecks.check(user);additionalAuthenticationChecks(user,(BindAuthenticationToken) authentication);} catch (AuthenticationException exception) {if (cacheWasUsed) {cacheWasUsed = false;user = retrieveUser(username,(BindAuthenticationToken) authentication);preAuthenticationChecks.check(user);additionalAuthenticationChecks(user,(BindAuthenticationToken) authentication);} else {throw exception;}}postAuthenticationChecks.check(user);if (!cacheWasUsed) {this.userCache.putUserInCache(user);}Object principalToReturn = user;if (forcePrincipalAsString) {principalToReturn = user.getUsername();}// 增加保存第三方用户信息BindAuthenticationToken bind = (BindAuthenticationToken) authentication;OtherSysUser bindInfo = (OtherSysUser) bind.getBindInfo();this.getOtherSysUserDao().save(bindInfo);return createSuccessAuthentication(principalToReturn, authentication, user);}protected Authentication createSuccessAuthentication(Object principal,Authentication authentication, UserDetails user) {BindAuthenticationToken result = new BindAuthenticationToken(principal, authentication.getCredentials(),authoritiesMapper.mapAuthorities(user.getAuthorities()));result.setDetails(authentication.getDetails());return result;}protected void doAfterPropertiesSet() throws Exception {}public UserCache getUserCache() {return userCache;}public boolean isForcePrincipalAsString() {return forcePrincipalAsString;}public boolean isHideUserNotFoundExceptions() {return hideUserNotFoundExceptions;}protected UserDetails retrieveUser(String username, BindAuthenticationToken authentication) throws AuthenticationException {try {UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);if (loadedUser == null) {throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");}return loadedUser;} catch (InternalAuthenticationServiceException ex) {throw ex;} catch (Exception ex) {throw new InternalAuthenticationServiceException(ex.getMessage(), ex);}}public void setForcePrincipalAsString(boolean forcePrincipalAsString) {this.forcePrincipalAsString = forcePrincipalAsString;}public void setHideUserNotFoundExceptions(boolean hideUserNotFoundExceptions) {this.hideUserNotFoundExceptions = hideUserNotFoundExceptions;}@Overridepublic void setMessageSource(MessageSource messageSource) {this.messages = new MessageSourceAccessor(messageSource);}public void setUserCache(UserCache userCache) {this.userCache = userCache;}@Overridepublic boolean supports(Class<?> authentication) {return (BindAuthenticationToken.class.isAssignableFrom(authentication));}protected UserDetailsChecker getPreAuthenticationChecks() {return preAuthenticationChecks;}public void setPreAuthenticationChecks(UserDetailsChecker preAuthenticationChecks) {this.preAuthenticationChecks = preAuthenticationChecks;}protected UserDetailsChecker getPostAuthenticationChecks() {return postAuthenticationChecks;}public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChecks) {this.postAuthenticationChecks = postAuthenticationChecks;}public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {this.authoritiesMapper = authoritiesMapper;}private class DefaultPreAuthenticationChecks implements UserDetailsChecker {@Overridepublic void check(UserDetails user) {if (!user.isAccountNonLocked()) {logger.debug("User account is locked");throw new LockedException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked","User account is locked"));}if (!user.isEnabled()) {logger.debug("User account is disabled");throw new DisabledException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled","User is disabled"));}if (!user.isAccountNonExpired()) {logger.debug("User account is expired");throw new AccountExpiredException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired","User account has expired"));}}}private class DefaultPostAuthenticationChecks implements UserDetailsChecker {@Overridepublic void check(UserDetails user) {if (!user.isCredentialsNonExpired()) {logger.debug("User account credentials have expired");throw new CredentialsExpiredException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired","User credentials have expired"));}}}/*** 密码验证部分* <p>* 根据密码的加密策略,对比用户输入密码和系统中存储的密码是否一致* 如果不一致,将抛出 BadCredentialsException** @param userDetails* @param authentication* @throws AuthenticationException*/protected void additionalAuthenticationChecks(UserDetails userDetails,BindAuthenticationToken authentication)throws AuthenticationException {if (authentication.getCredentials() == null) {logger.debug("Authentication failed: no credentials provided");throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials","Bad credentials"));}String presentedPassword = authentication.getCredentials().toString();if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {logger.debug("Authentication failed: password does not match stored value");throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials","Bad credentials"));}}
}

UserDetailsServiceImpl

这也是最开始实现对接 数据库的用户详情服务。


/**
* <h1>用户详细服务impl</h1>
* <p>
* 用于返回根据用户名返回用户详细信息,以便于供 security 使用
*
* @author gengzi
* @date 2020年11月3日15:24:43
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate UsersService usersService;@Autowiredprivate PasswordEncoder passwordEncoder;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {ReturnData result = usersService.loadUserByUsername(username);if (RspCodeEnum.NOTOKEN.getCode() == result.getStatus()) {throw new RrException(RspCodeEnum.ACCOUNT_NOT_EXIST.getDesc());}UserDetail userDetail = (UserDetail) result.getInfo();if (userDetail == null) {throw new RrException(RspCodeEnum.ACCOUNT_NOT_EXIST.getDesc());}//账号不可用if (userDetail.getStatus() == UserStatusEnum.DISABLE.getValue()) {userDetail.setEnabled(false);}return userDetail;}
}

核心配置

上述的代码写好后,都要加入Spring Security 的核心配置中。具体代码如下:

主要是把需要的类,都set进去。

代码参考:OtherSysOauth2LoginAuthenticationSecurityConfig


/**
* <h1>第三方登陆的认证配置</h1>
* <p>
* 主要配置:
* 1,加载第三方登陆的认证过滤器 ,主要提供路径拦截和基础判断
* 设置认证管理器
* 设置用户服务类
* <p>
* 2,加载登陆提供者
* 设置用户详细服务实现
* 设置登陆成功事件
* 设置登陆失败事件
* <p>
* 将提供提供者和过滤器加入 HttpSecurity 中,并在 UsernamePasswordAuthenticationFilter 前执行逻辑判断
* <p>
* 用户过滤器配置
*
* @author gengzi
* @date 2020年11月24日10:52:39
*/
@Configuration
public class OtherSysOauth2LoginAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {@Autowiredprivate OtherSysOauth2LoginUserDetailsServiceImpl extendUserDetailsService;// ------------ 用户认证失败处理程序 -------------@Autowiredprivate UserAuthenticationFailureHandler userAuthenticationFailureHandler;// ------------ 用户认证成功处理程序 -------------@Autowiredprivate UserAuthenticationSuccessHandler userAuthenticationSuccessHandler;@Autowiredprivate UsersService usersService;@Autowiredprivate RedisUtil redisUtil;@Autowiredprivate OtherSysUserDao otherSysUserDao;@Autowiredprivate SysUsersDao sysUsersDao;@Autowiredprivate OtherUsersService otherUsersService;@Autowiredprivate AuthRequestService authRequestService;@Autowiredprivate UserDetailsServiceImpl userDetailsService;@Autowiredprivate PasswordEncoder passwordEncoder;@Overridepublic void configure(HttpSecurity builder) throws Exception {// 第三方登陆过滤器OtherSysOauth2LoginFilter filter = new OtherSysOauth2LoginFilter();filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));filter.setUsersService(usersService);filter.setRedisUtil(redisUtil);filter.setOtherUsersService(otherUsersService);filter.setAuthenticationSuccessHandler(userAuthenticationSuccessHandler);filter.setAuthenticationFailureHandler(userAuthenticationFailureHandler);filter.setAuthRequestService(authRequestService);// 认证OtherSysOauth2LoginProvider provider = new OtherSysOauth2LoginProvider();provider.setUserDetailsService(extendUserDetailsService);builder.authenticationProvider(provider).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);// 绑定用户FilterUserBindFilter userBindFilter = new UserBindFilter();userBindFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));userBindFilter.setRedisUtil(redisUtil);userBindFilter.setOtherSysUserDao(otherSysUserDao);userBindFilter.setSysUsersDao(sysUsersDao);userBindFilter.setAuthenticationFailureHandler(userAuthenticationFailureHandler);userBindFilter.setAuthenticationSuccessHandler(userAuthenticationSuccessHandler);// 认证BindLoginProvider bindLoginProvider = new BindLoginProvider();bindLoginProvider.setOtherSysUserDao(otherSysUserDao);bindLoginProvider.setUserDetailsService(userDetailsService);bindLoginProvider.setPasswordEncoder(passwordEncoder);builder.authenticationProvider(bindLoginProvider).addFilterBefore(userBindFilter, UsernamePasswordAuthenticationFilter.class);}
}

并加入SpringSecurity 核心配置:

代码参考:WebSecurityConfig.java

只粘核心配置部分, 将OtherSysOauth2LoginAuthenticationSecurityConfig 加入SpringSecurity的过滤器链中

@Configuration
// 启用web 认证
@EnableWebSecurity
// 启用全局的方法安全
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {// ------------ 第三方登陆的认证安全配置 -------------@Autowiredprivate OtherSysOauth2LoginAuthenticationSecurityConfig otherSysOauth2LoginAuthenticationSecurityConfig;protected void configure(HttpSecurity http) throws Exception {// 自定义表单认证方式// ----------------- 关键配置 --------------------------http.apply(otherSysOauth2LoginAuthenticationSecurityConfig).and().// ----------------- 关键配置 --------------------------addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class).authorizeRequests()// 放行swagger-ui相关的路径.antMatchers(IgnoringUrlConstant.IGNORING_URLS).permitAll().antMatchers(IgnoringUrlConstant.IGNORING_STATIC_URLS).permitAll().antMatchers(IgnoringUrlConstant.OAUTH2_URLS).permitAll().antMatchers("/getLoginCode").permitAll().antMatchers("/codeBuildNew/**").permitAll()  // 都可以访问.anyRequest().authenticated().and().formLogin().loginPage("/login.html").loginProcessingUrl("/login").permitAll().and().csrf().disable()// csrf 防止跨站脚本攻击.formLogin().successHandler(userAuthenticationSuccessHandler).failureHandler(userAuthenticationFailureHandler).and().sessionManagement((sessionManagement) -> sessionManagement.maximumSessions(100).sessionRegistry(sessionRegistry()));}
}

综上所述

第三方登陆的实现依靠于JustAuth框架还是很简单的,对接SpringSecurity需要了解认证流程执行的类。上述的实现,只是作为第三方登陆演示的一种,需要优化的地方有很多,大家对于不同的需求,再修改适配即可,理解流程才是关键。

上述代码虽然能实现功能,但是编写的类还是太多,有没有更加简化的配置呢?当然有,SpringSecurity OAuth2 框架提供了第三方登陆更加优雅的解决方式,也是一种不错的选择。

听说点赞关注的人,身体健康,一夜暴富,升职加薪迎娶白富美!!!

点我领取每日福利
微信公众号:耿子blog
GitHub地址:gengzi

SpringSecurity实战(八)-通用第三方登陆-自定义认证配置实现相关推荐

  1. SpringSecurity实战:基于mysql自定义SpringSecurity权限认证规则

    上文<Spring Security 源码分析:Spring Security 授权过程>已经详细分析了Spring Security 授权过程,接下来通过上文的授权过程我们可以自定义授权 ...

  2. springSecurity的学习笔记--使用spring-Security完成表单登陆,手机验证码登陆,第三方登陆

    环境搭建好后,之后的练习进入了一个十分痛苦的阶段!! 但是与此同时,收获也是比较可观的. 老师通过详细的视频讲解,完成了表单登陆,包括账号密码和验证码登陆,手机验证码登陆,第三方登陆. 每一个部分都进 ...

  3. SpringSecurity使用自定义认证页面

    SpringSecurity使用自定义认证页面 在SpringSecurity主配置文件中指定认证页面配置信息 修改认证页面的请求地址 再次启动项目后就可以看到自定义的酷炫认证页面了!

  4. grpc、https、oauth2等认证专栏实战17:grpc-go自定义认证之base64验证介绍

    已发表的技术专栏(订阅即可观看所有专栏) 0  grpc-go.protobuf.multus-cni 技术专栏 总入口 1  grpc-go 源码剖析与实战  文章目录 2  Protobuf介绍与 ...

  5. python资格认证_Python怎么实现在后端的自定义认证并且实现多条件登陆

    JWT扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统Auth模型中提供的**authenticate()**来检查用户名与密码是否正确. 我们可以通过修改Django认证系统的认 ...

  6. SpringSecurity自定义认证成功处理器

    自定义认证成功处理器 代码实现 1.实现AuthenticationSuccessHandler接口 第一步:实现 AuthenticationSuccessHandler @Component(&q ...

  7. 利用FaceBook实现第三方登陆(自定义登陆按钮,非官方按钮)并获取用户数据

    最近公司要写集成登陆SDK,具体集成那些我就不说了,其中就包含需要使用facebook登陆自己的app,于是我苦心研究facebook,写完后发现各种问题,对于问题我当然去查阅官方文档看怎么解决,结果 ...

  8. 第六篇 :微信公众平台开发实战Java版之如何自定义微信公众号菜单

    我们来了解一下 自定义菜单创建接口: http请求方式:POST(请使用https协议) https://api.weixin.qq.com/cgi-bin/menu/create?access_to ...

  9. Spring Security 实战干货:玩转自定义登录

    点击上方蓝色"程序猿DD",选择"设为星标" 回复"资源"获取独家整理的学习资料! 1. 前言 前面的关于 Spring Security  ...

  10. 十年阿里巴巴资深架构师整理分享的SpringSecurity实战文档

    前言 SpringSecurity是一个强大且高度可定制的安全框架,致力于为Java应用提供身份认证和授权. Spring Security 的前身是 Acegi Security,在被收纳为Spri ...

最新文章

  1. AI:2020年6月23日北京智源大会演讲分享之智能信息检索与挖掘专题论坛——09:55-10:40刘欢教授《Challenges in Combating Disinformation》
  2. 阿里云天池平台官方出品!从0到1层层拆解天池大赛赛题 | 文末送书
  3. 算法设计原则验证实验报告_算法设计与分析实验报告 统计数字问题
  4. 自己动手之使用反射和泛型,动态读取XML创建类实例并赋值
  5. 动态内存分配到底为谁分配内存空间【浅谈动态内存的一个实例】
  6. 目前市场上有没有年化收益在7%以上,而且保本保息的理财?
  7. 五桌面工具来创建优秀的Windows环境
  8. linux密码带星号,Linux下实现输入密码以星号显示
  9. [Web 前端] SuperAgent中文使用文档
  10. Django 系列博客(十一)
  11. 基于RV1126平台imx291分析 --- media部件注册 mipi csi
  12. STM32学习记录0003——STM32芯片解读
  13. Roberts算子,matlab代码实现
  14. 岩土工程英语词汇A-R
  15. 应届生面试这样准备,最能展现自己优势!
  16. vue 在线阅读PDF
  17. STM32CUBEMX 配置12脚3641BS以及串口显示RTC时间
  18. md5sum命令的使用
  19. 基于ILP的最优PMU放置优化研究(Matlab代码实现)
  20. C++面向对象程序设计陈维兴版第四章所有例题

热门文章

  1. phpstudy的php fpm,浅谈PHP-FPM参数
  2. Java面向对象知识点总结
  3. 哈利波特英文单词统计频率
  4. SVN报错The working copy needs to be upgraded
  5. Netfilter学习之NAT类型动态配置(二)NAT类型介绍及MASQUERADE用户层的实现
  6. 【STM32】关于DMA控制器的介绍和使用
  7. PMP-7. 项目经理及其影响力
  8. 进入bios看了,vt 已经开了,为什么打开模拟器还显示未开启?
  9. 我的世界 Unity3D MineCraft 用Unity3D制作类似MineCraft我的世界的游戏 正经梳理一下开发01
  10. UE4中文汉字字体制作