SSO单点登录项目总结
SSO定义
SSO(Single Sign-On)单点登录是一种统一认证和授权机制,指访问同一服务器不同应用中的受保护资源的同一用户,只需登录一次,即通过一个应用中的安全验证后,再访问其他应用中的受保护资源时,不再需要重新登录验证。
方案简述
当前方案:
基于spring-boot + spring security + JWT + Oauth2 建立授权码授权机制
SpringSecurity
介绍参见《Spring实战》9.1节:
Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。
一般来说,Web 应用的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分。在用户认证方面,Spring Security 框架支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTTP 摘要认证、OpenID 和 LDAP 等。在用户授权方面,Spring Security 提供了基于角色的访问控制和访问控制列表(Access Control List,ACL),可以对应用中的领域对象进行细粒度的控制。
SpringSecurity官方参考文档:
OAuth2
OAuth(Open Authorization,开放授权)是为用户资源的授权定义了一个安全、开放及简单的标准,第三方无需知道用户的账号及密码,就可获取到用户的授权信息
OAuth2的整体流程图如下:
简单说就是:客户应用向授权服务器请求Access Token —> 授权服务器向用户征询意见,是否将权限授予客户应用(A) —> 用户同意(B) —> 授权服务器生成颁发Access Token给客户应用 —> 客户应用拿着Access Token去请求资源服务器 —> 资源服务器验证客户应用的Access Token —> 验证通过,返回数据
JWT:用于生成Token
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
JWT Token数据结构:
JWT由三段信息构成,将三段信息文本用 . 链接一起就构成了JWT字符串
Jwt 的头部header承载两部分信息:声明类型、声明加密的算法,然后将头部进行base64编码就构成了第一部分。
有效荷载playload就是存放有效信息的地方,主要包括:标准中注册的声明、公有的声明、私有的声明。进行base64编码就构成了第二部分。
签名signature是一个签证信息,这个签证信息由三部分组成:header(base64后)、payload(base64后)、secret。这部分需要base64加密后的header和base64加密后的payload使用. 连接组成字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后构成jwt的第三部分。
适合的场景:
1.一次性验证
比如用户注册后需要发一封邮件让其激活账户,通常邮件中需要有一个链接,这个链接需要具备以下的特性:能够标识用户,该链接具有时效性(通常只允许几小时之内激活),不能被篡改以激活其他可能的账户…这种场景就和 jwt 的特性非常贴近,jwt 的 payload 中固定的参数:iss 签发者和 exp 过期时间正是为其做准备的。
2.restful api 的无状态认证
使用 jwt 来做 restful api 的身份认证也是值得推崇的一种使用方案。客户端和服务端共享 secret;过期时间由服务端校验,客户端定时刷新;签名信息不可被修改…spring security oauth jwt 提供了一套完整的 jwt 认证体系,以笔者的经验来看:使用 oauth2 或 jwt 来做 restful api 的认证都没有大问题,oauth2 功能更多,支持的场景更丰富,后者实现简单。
3.使用 jwt 做单点登录
使用JWT增强token的参考:
https://blog.csdn.net/qq_42654484/article/details/95216037
流程图
采取的Oauth2模式为授权码模式,因此需要先获取授权码,后获取令牌token。
3-10步通过框架承载,只需要配置好框架中custom部分内容,Oauth2与SpringSecurity本身会自动实现跳转。第二步则是借用了SpringSecurity的filterChain,直接访问Gui时先会访问自定义的ssoFilter。该filter注册了Oauth2的客户端认证的所有信息。
-
用户访问gui地址:http://ip:port/gui/
-
Spring security检测会话不存在,重定向至sso地址,即
http://ip:port/sso-server/oauth/authorize?response_type=code&client_id=gui&redirect_uri=CALLBACK_URL&scope=read
response_type参数表示要求返回授权码(code),必须被设置到代码里;
client_id参数让sso知道是谁在请求, 当客户端被注册时,授权服务器要标识的客户端;
redirect_uri参数是sso接受或拒绝请求后的跳转网址,通过客户端注册的重定向URI
scope参数表示要求的授权范围(这里是只读)。请求可能的作用域(可选)
state 可选(推荐的)。任何需要被传递到客户端请求的URI客户端的状态。
https://ip:port/sso-server/oauth/authorize?client_id=gui&redirect_uri=https://ip:port/gui/login&response_type=code&state=OzqjWW -
Sso检测访问令牌access_token是否存在,不存在重定向到SSO登录界面
-
用户输入用户名密码,提交认证请求
-
Sso进行身份认证(local/nis/ldap):通过spring security做的
-
身份认证后颁发授权码,携带授权码重定向到GUI的回调地址
https://ip:port/gui/callback?code=AUTHORIZATION_CODE
code:表示授权码,必选项。被授权服务器接收到的授权码。该码的有效期应该很短,通常设为10分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端ID和重定向URI,是一一对应关系。
state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。 -
Gui后端向sso申请令牌(由于要传输secret key,因此只能从后端发起请求)
https://sso-server/oauth/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=CALLBACK_URL
client_id参数和client_secret参数用来让sso确认gui的身份(client_secret参数是保密的,因此只能在后端发请求);
grant_type参数的值是AUTHORIZATION_CODE,表示采用的授权方式是授权码;
code参数是上一步拿到的授权码;
redirect_uri参数是令牌颁发后的回调网址。 -
SSO验证client_secret一致后颁发令牌,JWT Token,携带用户信息(username)
access_token:表示访问令牌,必选项。
token_type:表示令牌类型,该值大小写不敏感,必选项,可以是bearer类型或mac类型
expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间
refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项。
scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。 -
GUI获取令牌后通过Oauth2验证,解析JWTtoken,用username建立GUI session会话
-
会话建立完成后跳转至登录后的首页。
SSO模块代码
SsoAuthorizationServerConfig
Oauth2配置类,继承AuthorizationServerConfigurerAdapter类,这个配置类完成了在SpringSecurity中配置Oauth2 + jwttoken的框架。
ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。
AuthorizationServerSecurityConfigurer:用来配置令牌端点(Token Endpoint)的安全约束.
AuthorizationServerEndpointsConfigurer:用来配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)。
jwtAccessTokenConverter()方法:TokenEnhancer的子类,帮助程序在JWT编码的令牌值和OAuth身份验证信息之间进行转换(在两个方向上),同时充当TokenEnhancer授予令牌的时间。本系统中设置自定义签名。
jwtTokenStore()方法,返回JwtTokenStore用于存储token
@Configuration
@EnableAuthorizationServer
public class SsoAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
/**
* 客户端配置
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 加解密组件提供互相认证的秘钥
String secretKey = SecretProvider.build();
try {
clients.inMemory()
// 客户端ID
.withClient("gui")
// 配置secretKey,配置便于双方能够识别到彼此的秘钥,即sso能识别出对方是gui,gui能识别出对方是sso
.secret("{noop}" + secretKey)
// 设置为授权码模式,并且设置授权码的失效时间为10s
.authorizedGrantTypes("authorization_code", "refresh_token")
.accessTokenValiditySeconds(10)
.scopes("all", "read", "write")
// 不配置的话会跳转到默认的oauth的页面,手动授权
.autoApprove(true)
// 配置重定向的URL
.redirectUris(“http://sso/login”);
} finally {
……
}
}
/**
* 配置jwt token Store,如果不配置的话采用oauth自带的token生成方式。这里实际上是用jwt来加强安全性的管理。
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// 第一个是配置JWTtoken存储形式
// 第二个配置token签名生成与解密的秘钥,同时指明配置为JwtAccessToken转换器。
// 参考:https://blog.csdn.net/qq_42654484/article/details/95216037
// 介绍了2个组件:JwtAccessTokenConverter:TokenEnhancer的子类,帮助程序在JWT编码的令牌值和OAuth身份验证信息之间进行转换(在两个方向上),同时充当TokenEnhancer授予令牌的时间。即下文的jwtAccessTokenConverter方法
// TokenEnhancer:在AuthorizationServerTokenServices 实现存储访问令牌之前增强访问令牌的策略。可以向其中增加某些附加信息。
// 链接里给出了进行令牌增强的demo,核心配置也是在这个配置方法中完成的。
endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());
}
/**
* springSecurity 授权表达式,访问/oauth/token_key获取merryyou时,使FilterSecurityInterceptor认证通过
* 授权端点开放
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 通过认证的开放授权
security.tokenKeyAccess("isAuthenticated()");
// clientDetail 信息里的client_secret字段加解密器。
security.passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
/**
* JWTtokenStore
*
* @return TokenStore
*/
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 生成JTW token,enhancer
* 配置jwt的第三部分签名的秘钥,这里是对称加密的方式,双方同时持有签名key
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("SignKey");
return converter;
}
}
SsoSecurityConfig
springSecurity 配置类,继承WebSecurityConfigurerAdapter类
configure(HttpSecurity http) 方法,配置Security的认证策略
configure(AuthenticationManagerBuilder auth)方法,配置认证信息
具体参考《Spring实战》P256
@Configuration
public class SsoSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService myUserDetailsService;
/**
* Password encoder password encoder.
* 配置类中完成bean的声明
* 这个bean的作用:SpringBoot认证时的密码hash的实装:https://blog.csdn.net/oblily/article/details/87866756
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 使用了Spring自带的标准PasswordEncoder,包含了主流的密码加解密方法
// 参考SpringSecurity官方文档第三章特性部分的加解密部分
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* Configure.
*
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 设置对于域名以及session的校验,属于安全需求,可以自定义各种校验方式
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
});
// session管理配置
http.sessionManagement()
// 最大session数目
.maximumSessions(1)
// session失效策略,这里属于custom自定义部分,自定义失效策略与失效行为
.expiredSessionStrategy(new MyExpiredSessionStrategy())
// 设为 false,效果和 QQ 登录是一样的,登陆后之前登录的账户被踢出。
.maxSessionsPreventsLogin(false)
.and()
.and()
// formLogin()对应表单认证相关的配置
.formLogin()
//登录页的URL,主要是展示登录页html
.loginPage("/authentication/loginPage")
// 设置登录的URL,在这个URL提交各种表单操作,可以在登录页html提交登录时跳转到这里
.loginProcessingUrl("/authentication/formData")
// 设置登录失败的处理,可以将各种失败信息塞回到response中,记录各种日志
// 自定义部分
// https://blog.csdn.net/qq_42033495/article/details/106617448
// 设置失败回调的demo
.failureHandler(new SsoAuthenticationFailureHandler())
.and()
.authorizeRequests()
// 设置无需进行SSO登录认证校验的各种url,一般是公共资源
.antMatchers(
"/**/*.jpg", "/**/*.png", "/**/*.svg", "/favicon.ico", "/error404", "/errorPage")
.permitAll()
.anyRequest()
.authenticated()
.and()
// csrf防护禁用
.csrf()
.disable();
}
/**
* Configure.
*
* 配置自定义的用户校验,可以提供自定义用户校验类,通常绑定着自定义token、自定义用户userdetail等
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
myAuthenticationProvider.setUserDetailsService(myUserDetailsService);
auth.authenticationProvider(myAuthenticationProvider);
/**
* PasswordEncoderFactories.createDelegatingPasswordEncoder()
* 需要自定义delegatingEncoder,需要委派类和ldap加密方式对应
*/
}
}
MyExpiredSessionStrategy
主要用来处理已经登录的session过期失效场景,设置比如重定向URL、记录日志等信息
public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
private static final ExtLogger logger = ExtLogger.create(MyExpiredSessionStrategy.class);
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
HttpServletRequest request = event.getRequest();
String redirectUrl = "……/authentication/require";
new SecurityContextLogoutHandler().logout(request, null, null);
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader("refer", redirectUrl);
try {
// 认证失败的重定向页面在这里修改,可以改为第三方SSO URL
response.sendRedirect(redirectUrl);
} catch (IOException e) {
logger.error("Redirect error. {}", e.getMessage());
}
}
}
MyAuthenticationFailureHandler
认证失败时的处理,这里用于根据抛出的异常信息,向前端提供相对应的错误码与错误原因
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
AuthenticationException ex) throws IOException, ServletException {
String username = httpServletRequest.getParameter("username") == null
? ""
: httpServletRequest.getParameter("username");
if (ex instanceof UsernameNotFoundException || ex instanceof BadCredentialsException) {
errorCode.append(MyErrorCode.XXXERROR);
logger.warn(……);
} else if (ex instanceof DisabledException) {
logger.warn(……);
} else {
if (ex != null && ex.getCause() != null) {
logger.warn(……);
}
}
String redirectUrl = httpServletRequest.getServletContext().getContextPath() + "/authentication/require?error";
httpServletResponse.sendRedirect(redirectUrl + errorCode.toString());
}
}
MyAuthenticationProvider
授权方式提供者,判断授权有效性,用户有效性,在判断用户是否有效性,它依赖于UserDetailsService实例,开发人员可以自定义UserDetailsService的实现。
- additionalAuthenticationChecks方法校验密码有效性
- retrieveUser方法根据用户名获取用户
- createSuccessAuthentication完成授权持久化
public class MyAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
// SpringSecurity自带user详细信息提取有关的接口,需要自己按照业务需求去实现该接口
// 一般provider + userdetailService + token三者捆绑
private UserDetailsService userDetailsService;
// 这里是采用ignite作为分布式缓存,是认证功能的能力加强,将用户登录有关的信息比如登录次数、登录具体时间、当前登录失败次数等缓存在ignite中,方便在用户登录时进行用户状态的判断。
private Ignite ignite = context.getBean("igniteInstance");
private PasswordEncoder passwordEncoder;
public MyAuthenticationProvider() {
// 将解析器设为Spring自带的密码加密解密器
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
// 校验密码有效性
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
String password = userDetails.getPassword();
// 省略各种校验
// 这个Authentication是项目自定义接口,这里采用了策略模式,AuthenticationFactory.getAuthentication()是个工厂类,用来根据配置文件中的值生成不同的校验策略,比如可以采用LADP校验、NIS校验、本地校验等不同的校验方式。
Authentication authtication = AuthenticationFactory.getAuthentication();
if (authtication != null) {
// 根据工厂生成的校验策略,对用户名密码进行校验
authtication.authentication(userDetails.getUsername(), password );
}
// build info
// ignite缓存更新,将部分登录上下文信息存入ignite中
IgniteCache<String, String> cache =
ignite.getOrCreateCache();
cache.put(userDetails.getUsername(), storeInfo);
}
/**
* retrieveUser
* 获取用户
*/
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
WebAuthenticationDetails webDetails = (WebAuthenticationDetails) authentication.getDetails();
// authentication可以为自定义的authenticationToken,自定义的类也要实现基本接口:获取用户名、密码以及额外信息等。
String password = authentication.getCredentials().toString();
// 返回由用户名、密码、权限集合构成的用户
UserDetails loadedUser =
new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"……");
}
return loadedUser;
}
}
UserDetailsService
springSecurity用于获取用户详细信息的接口
@Component
public class MyUserDetailsService implements UserDetailsService {
// MyService 与 MyProvider绑定
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String password = null;
try {
Authentication authentication = AuthenticationFactory.getAuthentication();
if (authentication != null) {
password = authentication.getPassword(username);
}
} catch (Exception e) {
throw new UsernameNotFoundException("……");
}
return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
}
}
GUI模块代码
SecurityConfig
springSecurity 配置类,继承WebSecurityConfigurerAdapter类
configure(HttpSecurity http) 方法,配置Security的认证策略
OauthFilter()方法,配置oauth拦截器,单点登录入口
jwtAccessTokenConverter()方法:TokenEnhancer的子类,帮助程序在JWT编码的令牌值和OAuth身份验证信息之间进行转换(在两个方向上),同时充当TokenEnhancer授予令牌的时间。本系统中设置自定义签名
oauth()配置clientSecretKey、accessTokenUri、clientId等
CsrfFilterRegistration()和CsrfHeaderFilterRegistration()设置csrf防御拦截器